multiwoven-integrations 0.42.0 → 0.43.0

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: 2aafe56dba0839f408ce9b67bd4a5a3e0e7b06081cea437da55299fb83236aa5
4
- data.tar.gz: 73de5d0ebc7c416a889f40c6e1131fb3b8d4fb15b8414b0ecfea0d068745dc52
3
+ metadata.gz: 62e0ad2ac6fc880f4b88dd4a511312b240645d5d1134966659a16225a927848d
4
+ data.tar.gz: a7368698f3dd5a28894549b635c136b348d43feb25513a1a93f8333a6f9c5ae7
5
5
  SHA512:
6
- metadata.gz: 9fe49be29f5706af6f8cc6df96394082143c419c56d82cde9222969fd1b45b949d00e1023317fa54e8b3171c649967b0ca8aa70aff8515745ef6cd15d0f8e323
7
- data.tar.gz: 80a9c368b79c7251c88692c30f17325690ebc6e96384ae4010ad8a8f878a091f523e50fe1d5a4a7ffaf6ca32922d20711609905b53fb70791ebda2fb5e2db2cc
6
+ metadata.gz: f0a58e7df26e7579cf5037d6b6605374695a07c6276279da1bd4b9582d7c9f797a001eb9ab7b52e41f402468c7e43c0958d1d1882f27f66aa92617e9a6bd8058
7
+ data.tar.gz: 8867babc43650be6e51e1a40c129089fa1b2ba46b561967b6d58bf251b3c48d3e42ef1d21ac46b5731d3a44012ffbd5edba298d76d621b3422db06f487b8eb5c
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Multiwoven
4
4
  module Integrations
5
- VERSION = "0.42.0"
5
+ VERSION = "0.43.0"
6
6
 
7
7
  ENABLED_SOURCES = %w[
8
8
  Snowflake
@@ -45,6 +45,7 @@ module Multiwoven
45
45
  OneDrive
46
46
  MicrosoftDynamics
47
47
  EpicFhir
48
+ SqlServer
48
49
  ].freeze
49
50
 
50
51
  ENABLED_DESTINATIONS = %w[
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tiny_tds"
4
+
5
+ module Multiwoven::Integrations::Source
6
+ module SqlServer
7
+ include Multiwoven::Integrations::Core
8
+ class Client < SourceConnector
9
+ def check_connection(connection_config)
10
+ connection_config = connection_config.with_indifferent_access
11
+ db = create_connection(connection_config)
12
+ ConnectionStatus.new(
13
+ status: ConnectionStatusType["succeeded"]
14
+ ).to_multiwoven_message
15
+ rescue TinyTds::Error => e
16
+ ConnectionStatus.new(
17
+ status: ConnectionStatusType["failed"],
18
+ message: e.message
19
+ ).to_multiwoven_message
20
+ ensure
21
+ db&.close
22
+ end
23
+
24
+ def discover(connection_config)
25
+ connection_config = connection_config.with_indifferent_access
26
+ schema = connection_config[:schema].presence || "dbo"
27
+ query = "SELECT table_name, column_name, data_type, is_nullable
28
+ FROM information_schema.columns
29
+ WHERE table_schema = '#{escape(schema)}'
30
+ ORDER BY table_name, ordinal_position;"
31
+
32
+ db = create_connection(connection_config)
33
+ records = db.execute(query).map do |row|
34
+ row.transform_keys { |key| key.to_s.downcase }
35
+ end
36
+ catalog = Catalog.new(streams: create_streams(records))
37
+ catalog.to_multiwoven_message
38
+ rescue StandardError => e
39
+ handle_exception(e, {
40
+ context: "SQLSERVER:DISCOVER:EXCEPTION",
41
+ type: "error"
42
+ })
43
+ ensure
44
+ db&.close
45
+ end
46
+
47
+ def read(sync_config)
48
+ connection_config = sync_config.source.connection_specification.with_indifferent_access
49
+ @pagination_primary_key = sync_config.model.primary_key
50
+ query = sync_config.model.query
51
+ query = batched_query(query, sync_config.limit, sync_config.offset) unless sync_config.limit.nil? && sync_config.offset.nil?
52
+
53
+ db = create_connection(connection_config)
54
+
55
+ query(db, query)
56
+ rescue StandardError => e
57
+ handle_exception(e, {
58
+ context: "SQLSERVER:READ:EXCEPTION",
59
+ type: "error",
60
+ sync_id: sync_config.sync_id,
61
+ sync_run_id: sync_config.sync_run_id
62
+ })
63
+ ensure
64
+ @pagination_primary_key = nil
65
+ db&.close
66
+ end
67
+
68
+ def search(vector_search_config)
69
+ connection_config = vector_search_config.source.connection_specification.with_indifferent_access
70
+ query = vector_search_config[:vector]
71
+ limit = vector_search_config[:limit]
72
+ query = batched_query(query, limit, 0) unless limit.nil?
73
+
74
+ db = create_connection(connection_config)
75
+ query(db, query)
76
+ rescue StandardError => e
77
+ handle_exception(e, {
78
+ context: "SQLSERVER:SEARCH:EXCEPTION",
79
+ type: "error"
80
+ })
81
+ ensure
82
+ db&.close
83
+ end
84
+
85
+ private
86
+
87
+ def query(connection, sql)
88
+ connection.execute(reformat_query(sql)).map do |row|
89
+ RecordMessage.new(data: row, emitted_at: Time.now.to_i).to_multiwoven_message
90
+ end
91
+ end
92
+
93
+ # SQL Server does not support LIMIT/OFFSET; use OFFSET/FETCH NEXT.
94
+ def batched_query(sql_query, limit, offset)
95
+ offset = offset.to_i
96
+ limit = limit&.to_i
97
+ raise ArgumentError, "Offset and limit must be non-negative" if offset.negative? || (!limit.nil? && limit.negative?)
98
+
99
+ sql_query = strip_trailing_terminator(sql_query)
100
+ raise ArgumentError, "Query already contains a LIMIT clause" if clause_outside_literals?(sql_query, /\bLIMIT\s+\d+\b/i)
101
+ raise ArgumentError, "Query already contains an OFFSET clause" if clause_outside_literals?(sql_query, /\bOFFSET\s+\d+\b/i)
102
+
103
+ apply_pagination(sql_query, limit, offset)
104
+ end
105
+
106
+ # Convert Postgres/MySQL-style LIMIT/OFFSET (from upstream, including query_source) into SQL Server pagination.
107
+ def reformat_query(sql_query)
108
+ sql_query = strip_trailing_terminator(sql_query)
109
+
110
+ return sql_query if clause_outside_literals?(sql_query, /\bOFFSET\s+\d+\s+ROWS\b/i)
111
+
112
+ limit = nil
113
+ offset = nil
114
+
115
+ normalized = with_masked_literals(sql_query) do |masked|
116
+ if (match = masked.match(/\bLIMIT\s+(\d+)\b/i))
117
+ limit = match[1].to_i
118
+ masked = masked.sub(/\bLIMIT\s+\d+\b/i, "")
119
+ end
120
+
121
+ if (match = masked.match(/\bOFFSET\s+(\d+)\b(?!\s+ROWS)/i))
122
+ offset = match[1].to_i
123
+ masked = masked.sub(/\bOFFSET\s+\d+\b(?!\s+ROWS)/i, "")
124
+ end
125
+
126
+ masked.gsub(/[^\S\n]+/, " ").strip
127
+ end
128
+
129
+ return normalized if limit.nil? && offset.nil?
130
+
131
+ apply_pagination(normalized, limit, offset || 0)
132
+ end
133
+
134
+ def apply_pagination(sql_query, limit, offset)
135
+ raise ArgumentError, "Limit must be at least 1" if !limit.nil? && limit.to_i < 1
136
+
137
+ keys = normalize_primary_keys(@pagination_primary_key)
138
+ has_order = clause_outside_literals?(sql_query, /\bORDER\s+BY\b/i)
139
+
140
+ # query_source appends LIMIT without ORDER BY or primary_key metadata.
141
+ # TOP does not require ORDER BY; OFFSET/FETCH does.
142
+ return apply_top(sql_query, limit) if offset.to_i.zero? && !limit.nil? && !has_order && keys.empty?
143
+
144
+ sql_query = ensure_stable_order(sql_query, keys: keys, has_order: has_order)
145
+
146
+ clause = "OFFSET #{offset.to_i} ROWS"
147
+ clause = "#{clause} FETCH NEXT #{limit.to_i} ROWS ONLY" unless limit.nil?
148
+
149
+ "#{sql_query} #{clause}"
150
+ end
151
+
152
+ def apply_top(sql_query, limit)
153
+ with_masked_literals(sql_query) do |masked|
154
+ raise ArgumentError, "Query already contains TOP" if masked.match?(/\A\s*SELECT\s+(?:(?:DISTINCT|ALL)\s+)?TOP\b/i)
155
+
156
+ pattern = /\A\s*SELECT\s+((?:DISTINCT|ALL)\s+)?/i
157
+ raise ArgumentError, "Cannot apply TOP to this query; add ORDER BY" unless masked.match?(pattern)
158
+
159
+ masked.sub(pattern) { "SELECT #{Regexp.last_match(1)}TOP #{limit.to_i} " }
160
+ end
161
+ end
162
+
163
+ def ensure_stable_order(sql_query, keys:, has_order:)
164
+ return append_order_tie_breakers(sql_query, keys) if has_order
165
+
166
+ if keys.empty?
167
+ raise ArgumentError,
168
+ "Paginated SQL Server queries require an ORDER BY with a unique key, or a model primary_key"
169
+ end
170
+
171
+ "#{sql_query} ORDER BY #{order_by_list(keys)}"
172
+ end
173
+
174
+ def append_order_tie_breakers(sql_query, keys)
175
+ missing = keys.reject { |key| order_by_includes_key?(sql_query, key) }
176
+ return sql_query if missing.empty?
177
+
178
+ "#{sql_query}, #{order_by_list(missing)}"
179
+ end
180
+
181
+ def order_by_includes_key?(sql_query, key)
182
+ included = false
183
+ with_masked_literals(sql_query) do |masked|
184
+ order_clause = masked[/\bORDER\s+BY\b(.+)\z/im, 1]
185
+ if order_clause
186
+ quoted = Regexp.escape(quote_identifier(key))
187
+ bare = Regexp.escape(key.to_s)
188
+ included = order_clause.match?(/#{quoted}|\b#{bare}\b/i)
189
+ end
190
+ masked
191
+ end
192
+ included
193
+ end
194
+
195
+ def normalize_primary_keys(primary_key)
196
+ Array(primary_key).flat_map { |key| key.to_s.split(",") }.map(&:strip).reject(&:empty?).uniq
197
+ end
198
+
199
+ def order_by_list(keys)
200
+ keys.map { |key| quote_identifier(key) }.join(", ")
201
+ end
202
+
203
+ def quote_identifier(name)
204
+ "[#{name.to_s.gsub("]", "]]")}]"
205
+ end
206
+
207
+ def strip_trailing_terminator(sql_query)
208
+ with_masked_literals(sql_query.to_s.strip) do |masked|
209
+ masked = masked.rstrip
210
+ masked = masked.chomp(";") while masked.end_with?(";")
211
+ masked
212
+ end
213
+ end
214
+
215
+ def clause_outside_literals?(sql_query, pattern)
216
+ matched = false
217
+ with_masked_literals(sql_query) do |masked|
218
+ matched = masked.match?(pattern)
219
+ masked
220
+ end
221
+ matched
222
+ end
223
+
224
+ # Mask single-quoted SQL literals (including N'...' and escaped '') so rewrites
225
+ # do not touch values like 'a;b' or 'LIMIT 1'.
226
+ def with_masked_literals(sql_query)
227
+ literals = []
228
+ masked = sql_query.to_s.gsub(/(?:N)?'(?:''|[^'])*'/i) do |literal|
229
+ literals << literal
230
+ "__SQL_LITERAL_#{literals.length - 1}__"
231
+ end
232
+
233
+ result = yield(masked)
234
+ literals.each_with_index do |literal, index|
235
+ result = result.gsub("__SQL_LITERAL_#{index}__", literal)
236
+ end
237
+ result
238
+ end
239
+
240
+ def create_connection(connection_config)
241
+ raise "Unsupported Auth type" unless connection_config[:credentials][:auth_type] == "username/password"
242
+
243
+ TinyTds::Client.new(
244
+ username: connection_config[:credentials][:username],
245
+ password: connection_config[:credentials][:password],
246
+ host: connection_config[:host],
247
+ port: connection_config[:port].presence || 1433,
248
+ database: connection_config[:database],
249
+ timeout: 10,
250
+ azure: connection_config[:azure].presence || false
251
+ )
252
+ end
253
+
254
+ def create_streams(records)
255
+ group_by_table(records).map do |r|
256
+ Multiwoven::Integrations::Protocol::Stream.new(name: r[:tablename], action: StreamAction["fetch"], json_schema: convert_to_json_schema(r[:columns]))
257
+ end
258
+ end
259
+
260
+ def group_by_table(records)
261
+ records.group_by { |entry| entry["table_name"] }.map do |table_name, columns|
262
+ {
263
+ tablename: table_name,
264
+ columns: columns.map do |column|
265
+ {
266
+ column_name: column["column_name"],
267
+ type: column["data_type"],
268
+ optional: column["is_nullable"] == "YES"
269
+ }
270
+ end
271
+ }
272
+ end
273
+ end
274
+
275
+ def escape(value)
276
+ value.to_s.gsub("'", "''")
277
+ end
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,16 @@
1
+ {
2
+ "data": {
3
+ "name": "SqlServer",
4
+ "title": "SQL Server",
5
+ "connector_type": "source",
6
+ "category": "Data Warehouse",
7
+ "sub_category": "Relational Database",
8
+ "documentation_url": "https://docs.squared.ai/guides/sources/data-sources/sql-server",
9
+ "github_issue_label": "source-sql-server",
10
+ "icon": "https://res.cloudinary.com/dspflukeu/image/upload/v1790358609/Multiwoven/connectors/sql_server/icon.svg",
11
+ "license": "MIT",
12
+ "release_stage": "alpha",
13
+ "support_level": "community",
14
+ "tags": ["language:ruby", "multiwoven"]
15
+ }
16
+ }
@@ -0,0 +1,94 @@
1
+ {
2
+ "documentation_url": "https://docs.squared.ai/guides/sources/data-sources/sql-server",
3
+ "stream_type": "dynamic",
4
+ "connector_query_type": "raw_sql",
5
+ "connection_specification": {
6
+ "$schema": "http://json-schema.org/draft-07/schema#",
7
+ "title": "SqlServer",
8
+ "type": "object",
9
+ "required": ["data_type", "credentials", "host", "database", "schema"],
10
+ "properties": {
11
+ "data_type": {
12
+ "description": "Type of data in the database",
13
+ "type": "string",
14
+ "title": "Data Format Type",
15
+ "oneOf": [
16
+ {
17
+ "const": "structured",
18
+ "title": "Tables & Records (Structured)"
19
+ },
20
+ {
21
+ "const": "vector",
22
+ "title": "Tables & Records containing Vector fields"
23
+ }
24
+ ],
25
+ "default": "structured",
26
+ "order": 0
27
+ },
28
+ "credentials": {
29
+ "title": "",
30
+ "type": "object",
31
+ "required": ["auth_type", "username", "password"],
32
+ "properties": {
33
+ "auth_type": {
34
+ "type": "string",
35
+ "default": "username/password",
36
+ "order": 0,
37
+ "readOnly": true
38
+ },
39
+ "username": {
40
+ "description": "Username refers to your individual SQL Server login credentials. At a minimum, the user associated with these credentials must be granted read access to the data intended for synchronization.",
41
+ "examples": ["SQLSERVER_USER"],
42
+ "type": "string",
43
+ "title": "Username",
44
+ "order": 1
45
+ },
46
+ "password": {
47
+ "description": "This field requires the password associated with the SQL Server login specified in the preceding section.",
48
+ "type": "string",
49
+ "multiwoven_secret": true,
50
+ "title": "Password",
51
+ "order": 2
52
+ }
53
+ },
54
+ "order": 1
55
+ },
56
+ "host": {
57
+ "description": "The hostname or IP address of your SQL Server instance.",
58
+ "examples": ["127.0.0.1"],
59
+ "type": "string",
60
+ "title": "Host",
61
+ "order": 2
62
+ },
63
+ "port": {
64
+ "description": "The port number for your SQL Server instance, which defaults to 1433 and may vary based on your configuration.",
65
+ "examples": ["1433"],
66
+ "type": "string",
67
+ "title": "Port",
68
+ "order": 3
69
+ },
70
+ "database": {
71
+ "description": "The specific SQL Server database to connect to.",
72
+ "examples": ["SQLSERVER_DB"],
73
+ "type": "string",
74
+ "title": "Database",
75
+ "order": 4
76
+ },
77
+ "schema": {
78
+ "description": "The schema within the SQL Server database, typically 'dbo'.",
79
+ "examples": ["dbo"],
80
+ "default": "dbo",
81
+ "type": "string",
82
+ "title": "Schema",
83
+ "order": 5
84
+ },
85
+ "azure": {
86
+ "description": "Enable when connecting to Azure SQL Database (required for Azure encryption/login behaviour).",
87
+ "type": "boolean",
88
+ "title": "Azure SQL",
89
+ "default": false,
90
+ "order": 6
91
+ }
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,22 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+
3
+ <svg width="800px" height="800px" viewBox="0 -141.54 1478.201 1478.201" xmlns="http://www.w3.org/2000/svg">
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
@@ -113,6 +113,7 @@ require_relative "integrations/source/aisquared/client"
113
113
  require_relative "integrations/source/one_drive/client"
114
114
  require_relative "integrations/source/microsoft_dynamics/client"
115
115
  require_relative "integrations/source/epic_fhir/client"
116
+ require_relative "integrations/source/sql_server/client"
116
117
 
117
118
  # Destination
118
119
  require_relative "integrations/destination/klaviyo/client"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: multiwoven-integrations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.42.0
4
+ version: 0.43.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Subin T P
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-24 00:00:00.000000000 Z
11
+ date: 2026-09-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -913,6 +913,10 @@ files:
913
913
  - lib/multiwoven/integrations/source/snowflake/config/meta.json
914
914
  - lib/multiwoven/integrations/source/snowflake/config/spec.json
915
915
  - lib/multiwoven/integrations/source/snowflake/icon.svg
916
+ - lib/multiwoven/integrations/source/sql_server/client.rb
917
+ - lib/multiwoven/integrations/source/sql_server/config/meta.json
918
+ - lib/multiwoven/integrations/source/sql_server/config/spec.json
919
+ - lib/multiwoven/integrations/source/sql_server/icon.svg
916
920
  - lib/multiwoven/integrations/source/watsonx_ai/client.rb
917
921
  - lib/multiwoven/integrations/source/watsonx_ai/config/catalog.json
918
922
  - lib/multiwoven/integrations/source/watsonx_ai/config/meta.json