Skip to content

Commit 654570a

Browse files
authored
v4: Refactor tiny_tds to avoid sharing DBPROCESS (#595)
* Move `insert` to client class * Move `do` to client class * Refactor `execute` to fetch an entire result object * Ensure test database data is loaded before running tests * Update `CHANGELOG` * Use `int64_t` instead of custom `LONG_LONG_FORMAT` macro
1 parent d6b9318 commit 654570a

File tree

14 files changed

+883
-1294
lines changed

14 files changed

+883
-1294
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
* Drop support for Ruby < 3.2
44
* Drop support for SQL Server < 2019
5+
* Removed lazy-loading of results for `execute`
6+
* Moved `#do`, `#insert` and `#execute` methods to the `TinyTds::Client` class
7+
* `TinyTds::Result` is now a pure Ruby class
8+
* `#execute`: Replaced `opts` hash with keyword arguments
9+
* Removed `symbolize_keys` and `cache_rows` from `#default_query_options`
510

611
## 3.3.0
712

README.md

Lines changed: 51 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,8 @@ opts[:message_handler] = Proc.new { |m| puts m.message }
122122
client = TinyTds::Client.new opts
123123
# => Changed database context to 'master'.
124124
# => Changed language setting to us_english.
125-
client.execute("print 'hello world!'").do
126-
# => hello world!
125+
client.do("print 'hello world!'")
126+
# => -1 (no affected rows)
127127
```
128128

129129
Use the `#active?` method to determine if a connection is good. The implementation of this method may change but it should always guarantee that a connection is good. Current it checks for either a closed or dead connection.
@@ -153,169 +153,99 @@ Send a SQL string to the database and return a TinyTds::Result object.
153153
result = client.execute("SELECT * FROM [datatypes]")
154154
```
155155

156+
## Sending queries and receiving results
156157

157-
## TinyTds::Result Usage
158+
The client implements three different methods to send queries to a SQL server.
158159

159-
A result object is returned by the client's execute command. It is important that you either return the data from the query, most likely with the #each method, or that you cancel the results before asking the client to execute another SQL batch. Failing to do so will yield an error.
160-
161-
Calling #each on the result will lazily load each row from the database.
160+
`client.insert` will execute the query and return the last identifier.
162161

163162
```ruby
164-
result.each do |row|
165-
# By default each row is a hash.
166-
# The keys are the fields, as you'd expect.
167-
# The values are pre-built Ruby primitives mapped from their corresponding types.
168-
end
163+
client.insert("INSERT INTO [datatypes] ([varchar_50]) VALUES ('text')")
164+
# => 363
169165
```
170166

171-
A result object has a `#fields` accessor. It can be called before the result rows are iterated over. Even if no rows are returned, #fields will still return the column names you expected. Any SQL that does not return columned data will always return an empty array for `#fields`. It is important to remember that if you access the `#fields` before iterating over the results, the columns will always follow the default query option's `:symbolize_keys` setting at the client's level and will ignore the query options passed to each.
167+
`client.do` will execute the query and tell you how many rows were affected.
172168

173169
```ruby
174-
result = client.execute("USE [tinytdstest]")
175-
result.fields # => []
176-
result.do
177-
178-
result = client.execute("SELECT [id] FROM [datatypes]")
179-
result.fields # => ["id"]
180-
result.cancel
181-
result = client.execute("SELECT [id] FROM [datatypes]")
182-
result.each(:symbolize_keys => true)
183-
result.fields # => [:id]
170+
client.do("DELETE FROM [datatypes] WHERE [varchar_50] = 'text'")
171+
# 1
184172
```
185173

186-
You can cancel a result object's data from being loading by the server.
174+
Both `do` and `insert` will not serialize any results sent by the SQL server, making them extremely fast and memory-efficient for large operations.
175+
176+
`client.execute` will execute the query and return you a `TinyTds::Result` object.
187177

188178
```ruby
189-
result = client.execute("SELECT * FROM [super_big_table]")
190-
result.cancel
179+
client.execute("SELECT [id] FROM [datatypes]")
180+
# =>
181+
# #<TinyTds::Result:0x000057d6275ce3b0
182+
# @fields=["id"],
183+
# @return_code=nil,
184+
# @rows=
185+
# [{"id"=>11},
186+
# {"id"=>12},
187+
# {"id"=>21},
188+
# {"id"=>31},
191189
```
192190

193-
You can use results cancelation in conjunction with results lazy loading, no problem.
191+
A result object has a `fields` accessor. Even if no rows are returned, `fields` will still return the column names you expected. Any SQL that does not return columned data will always return an empty array for `fields`.
194192

195193
```ruby
196-
result = client.execute("SELECT * FROM [super_big_table]")
197-
result.each_with_index do |row, i|
198-
break if row > 10
199-
end
200-
result.cancel
194+
result = client.execute("USE [tinytdstest]")
195+
result.fields # => []
196+
197+
result = client.execute("SELECT [id] FROM [datatypes]")
198+
result.fields # => ["id"]
201199
```
202200

203-
If the SQL executed by the client returns affected rows, you can easily find out how many.
201+
You can retrieve the results by accessing the `rows` property on the result.
204202

205203
```ruby
206-
result.each
207-
result.affected_rows # => 24
204+
result.rows
205+
# =>
206+
# [{"id"=>11},
207+
# {"id"=>12},
208+
# {"id"=>21},
209+
# ...
208210
```
209211

210-
This pattern is so common for UPDATE and DELETE statements that the #do method cancels any need for loading the result data and returns the `#affected_rows`.
212+
The result object also has `affected_rows`, which usually also corresponds to the length of items in `rows`. But if you execute a `DELETE` statement with `execute, `rows` is likely empty but `affected_rows` will still list a couple of items.
211213

212214
```ruby
213215
result = client.execute("DELETE FROM [datatypes]")
214-
result.do # => 72
216+
# #<TinyTds::Result:0x00005efc024d9f10 @affected_rows=75, @fields=[], @return_code=nil, @rows=[]>
217+
result.count
218+
# 0
219+
result.affected_rows
220+
# 75
215221
```
216222

217-
Likewise for `INSERT` statements, the #insert method cancels any need for loading the result data and executes a `SCOPE_IDENTITY()` for the primary key.
218-
219-
```ruby
220-
result = client.execute("INSERT INTO [datatypes] ([xml]) VALUES ('<html><br/></html>')")
221-
result.insert # => 420
222-
```
223+
But as mentioned earlier, best use `do` when you are only interested in the `affected_rows`.
223224

224-
The result object can handle multiple result sets form batched SQL or stored procedures. It is critical to remember that when calling each with a block for the first time will return each "row" of each result set. Calling each a second time with a block will yield each "set".
225+
The result object can handle multiple result sets form batched SQL or stored procedures.
225226

226227
```ruby
227228
sql = ["SELECT TOP (1) [id] FROM [datatypes]",
228229
"SELECT TOP (2) [bigint] FROM [datatypes] WHERE [bigint] IS NOT NULL"].join(' ')
229230

230-
set1, set2 = client.execute(sql).each
231+
set1, set2 = client.execute(sql).rows
231232
set1 # => [{"id"=>11}]
232233
set2 # => [{"bigint"=>-9223372036854775807}, {"bigint"=>9223372036854775806}]
233-
234-
result = client.execute(sql)
235-
236-
result.each do |rowset|
237-
# First time data loading, yields each row from each set.
238-
# 1st: {"id"=>11}
239-
# 2nd: {"bigint"=>-9223372036854775807}
240-
# 3rd: {"bigint"=>9223372036854775806}
241-
end
242-
243-
result.each do |rowset|
244-
# Second time over (if columns cached), yields each set.
245-
# 1st: [{"id"=>11}]
246-
# 2nd: [{"bigint"=>-9223372036854775807}, {"bigint"=>9223372036854775806}]
247-
end
248-
```
249-
250-
Use the `#sqlsent?` and `#canceled?` query methods on the client to determine if an active SQL batch still needs to be processed and or if data results were canceled from the last result object. These values reset to true and false respectively for the client at the start of each `#execute` and new result object. Or if all rows are processed normally, `#sqlsent?` will return false. To demonstrate, lets assume we have 100 rows in the result object.
251-
252-
```ruby
253-
client.sqlsent? # = false
254-
client.canceled? # = false
255-
256-
result = client.execute("SELECT * FROM [super_big_table]")
257-
258-
client.sqlsent? # = true
259-
client.canceled? # = false
260-
261-
result.each do |row|
262-
# Assume we break after 20 rows with 80 still pending.
263-
break if row["id"] > 20
264-
end
265-
266-
client.sqlsent? # = true
267-
client.canceled? # = false
268-
269-
result.cancel
270-
271-
client.sqlsent? # = false
272-
client.canceled? # = true
273-
```
274-
275-
It is possible to get the return code after executing a stored procedure from either the result or client object.
276-
277-
```ruby
278-
client.return_code # => nil
279-
280-
result = client.execute("EXEC tinytds_TestReturnCodes")
281-
result.do
282-
result.return_code # => 420
283-
client.return_code # => 420
284234
```
285235

286-
287236
## Query Options
288237

289-
Every `TinyTds::Result` object can pass query options to the #each method. The defaults are defined and configurable by setting options in the `TinyTds::Client.default_query_options` hash. The default values are:
290-
291-
* :as => :hash - Object for each row yielded. Can be set to :array.
292-
* :symbolize_keys => false - Row hash keys. Defaults to shared/frozen string keys.
293-
* :cache_rows => true - Successive calls to #each returns the cached rows.
294-
* :timezone => :local - Local to the Ruby client or :utc for UTC.
295-
* :empty_sets => true - Include empty results set in queries that return multiple result sets.
238+
You can pass query options to `execute`. The defaults are defined and configurable by setting options in the `TinyTds::Client.default_query_options` hash. The default values are:
296239

297-
Each result gets a copy of the default options you specify at the client level and can be overridden by passing an options hash to the #each method. For example
240+
* `as: :hash` - Object for each row yielded. Can be set to :array.
241+
* `empty_sets: true` - Include empty results set in queries that return multiple result sets.
242+
* `timezone: :local` - Local to the Ruby client or :utc for UTC.
298243

299244
```ruby
300-
result.each(:as => :array, :cache_rows => false) do |row|
301-
# Each row is now an array of values ordered by #fields.
302-
# Rows are yielded and forgotten about, freeing memory.
303-
end
245+
result = client.execute("SELECT [datetime2_2] FROM [datatypes] WHERE [id] = 74", as: :array, timezone: :utc, empty_sets: true)
246+
# => #<TinyTds::Result:0x000061e841910600 @affected_rows=1, @fields=["datetime2_2"], @return_code=nil, @rows=[[9999-12-31 23:59:59.12 UTC]]>
304247
```
305248

306-
Besides the standard query options, the result object can take one additional option. Using `:first => true` will only load the first row of data and cancel all remaining results.
307-
308-
```ruby
309-
result = client.execute("SELECT * FROM [super_big_table]")
310-
result.each(:first => true) # => [{'id' => 24}]
311-
```
312-
313-
314-
## Row Caching
315-
316-
By default row caching is turned on because the SQL Server adapter for ActiveRecord would not work without it. I hope to find some time to create some performance patches for ActiveRecord that would allow it to take advantages of lazily created yielded rows from result objects. Currently only TinyTDS and the Mysql2 gem allow such a performance gain.
317-
318-
319249
## Encoding Error Handling
320250

321251
TinyTDS takes an opinionated stance on how we handle encoding errors. First, we treat errors differently on reads vs. writes. Our opinion is that if you are reading bad data due to your client's encoding option, you would rather just find `?` marks in your strings vs being blocked with exceptions. This is how things wold work via ODBC or SMS. On the other hand, writes will raise an exception. In this case we raise the SYBEICONVO/2402 error message which has a description of `Error converting characters into server's character set. Some character(s) could not be converted.`. Even though the severity of this message is only a `4` and TinyTDS will automatically strip/ignore unknown characters, we feel you should know that you are inserting bad encodings. In this way, a transaction can be rolled back, etc. Remember, any database write that has bad characters due to the client encoding will still be written to the database, but it is up to you rollback said write if needed. Most ORMs like ActiveRecord handle this scenario just fine.

0 commit comments

Comments
 (0)