Skip to content

Commit 7d9c1f4

Browse files
authored
Upgrade: Introduce Parameter Object: QueryIntent (#1380)
1 parent f85c8ba commit 7d9c1f4

File tree

10 files changed

+251
-27
lines changed

10 files changed

+251
-27
lines changed

AGENTS.md

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# AGENTS.md
2+
3+
This file contains guidelines and commands for agentic coding agents working in the ActiveRecord SQL Server Adapter repository.
4+
5+
## Build, Lint & Test Commands
6+
7+
### Core Commands
8+
```bash
9+
# Install dependencies
10+
bundle install
11+
12+
# Run all tests
13+
bundle exec rake test
14+
15+
# Run specific test files
16+
bundle exec ruby -Ilib:test test/cases/adapter_test_sqlserver.rb
17+
18+
# Run tests with environment variables
19+
ONLY_SQLSERVER=1 bundle exec rake test
20+
ONLY_ACTIVERECORD=1 bundle exec rake test
21+
22+
# Run specific tests using TEST_FILES environment variable
23+
TEST_FILES=test/cases/adapter_test_sqlserver.rb,test/cases/schema_test_sqlserver.rb bundle exec rake test:dblib
24+
25+
# Run specific ActiveRecord tests using TEST_FILES_AR
26+
TEST_FILES_AR=test/cases/adapters/mysql2/schema_test.rb bundle exec rake test:dblib
27+
28+
# Enable warnings during testing
29+
WARNING=1 bundle exec rake test:dblib
30+
31+
# Run performance profiling
32+
bundle exec rake profile:dblib:[profile_case_name]
33+
```
34+
35+
### Code Quality
36+
```bash
37+
# Run Standard Ruby formatter/linter
38+
bundle exec standardrb
39+
40+
# Run Standard with fix
41+
bundle exec standardrb --fix
42+
43+
# Check only (no modifications)
44+
bundle exec standardrb --format progress
45+
```
46+
47+
## Code Style Guidelines
48+
49+
### General Standards
50+
- **Ruby Version**: Target Ruby 3.2.0+ (uses frozen_string_literal: true)
51+
- **Code Style**: Uses Standard Ruby formatter
52+
- **Line Length**: 120 characters max
53+
- **String Literals**: Double quotes preferred (enforced by RuboCop)
54+
- **Encoding**: Always include `# frozen_string_literal: true` at top of files
55+
56+
### Import/Require Organization
57+
```ruby
58+
# frozen_string_literal: true
59+
60+
# External libraries first
61+
require "tiny_tds"
62+
require "base64"
63+
require "active_record"
64+
65+
# Internal libraries next, grouped by functionality
66+
require "active_record/connection_adapters/sqlserver/version"
67+
require "active_record/connection_adapters/sqlserver/type"
68+
require "active_record/connection_adapters/sqlserver/database_limits"
69+
# ... etc
70+
71+
# Local requires last
72+
require_relative "support/paths_sqlserver"
73+
```
74+
75+
### Naming Conventions
76+
- **Files**: snake_case with `_test_sqlserver.rb` suffix for test files
77+
- **Classes**: PascalCase, test classes end with `SQLServer` suffix
78+
- **Methods**: snake_case, use descriptive names
79+
- **Constants**: SCREAMING_SNAKE_CASE
80+
- **Modules**: PascalCase, logical grouping (e.g., `SQLServer::Type`)
81+
82+
### Module Structure
83+
```ruby
84+
module ActiveRecord
85+
module ConnectionAdapters
86+
module SQLServer
87+
module Type
88+
class Boolean < ActiveRecord::Type::Boolean
89+
def sqlserver_type
90+
"bit"
91+
end
92+
end
93+
end
94+
end
95+
end
96+
end
97+
```
98+
99+
### Testing Patterns
100+
- **Test Files**: Follow pattern `*_test_sqlserver.rb` in `test/cases/`
101+
- **Test Classes**: Inherit from `ActiveRecord::TestCase`
102+
- **Fixtures**: Use `fixtures :table_name` when needed
103+
- **Let Blocks**: Use `let` for test setup data
104+
- **Assertions**: Use custom assertions from `ARTest::SQLServer::QueryAssertions`
105+
106+
### Error Handling
107+
- Use SQL Server-specific error classes from `ActiveRecord::ConnectionAdapters::SQLServer::Errors`
108+
- Handle TinyTDS errors appropriately
109+
- Log database errors with context
110+
111+
### Type System
112+
- All SQL Server types inherit from corresponding ActiveRecord types
113+
- Implement `sqlserver_type` method for database type mapping
114+
- Location: `lib/active_record/connection_adapters/sqlserver/type/`
115+
116+
### Connection Management
117+
- Use `ActiveRecord::Base.lease_connection` for connection access
118+
- Handle connection pooling and timeout scenarios
119+
- Support both dblib modes
120+
121+
### Schema Statements
122+
- Follow SQL Server-specific SQL syntax
123+
- Use proper identifier quoting with square brackets: `[table_name]`
124+
- Handle SQL Server's quirks around primary keys, indexes, and constraints
125+
126+
## File Organization
127+
128+
### Core Structure
129+
```
130+
lib/
131+
├── active_record/
132+
│ └── connection_adapters/
133+
│ └── sqlserver/ # Main adapter implementation
134+
│ ├── type/ # SQL Server-specific types
135+
│ └── core_ext/ # ActiveRecord extensions
136+
test/
137+
├── cases/ # Test files (*_test_sqlserver.rb)
138+
├── support/ # Test utilities and helpers
139+
└── migrations/ # Test migrations
140+
```
141+
142+
### Module Dependencies
143+
- Core adapter: `lib/active_record/connection_adapters/sqlserver_adapter.rb`
144+
- Types: `lib/active_record/connection_adapters/sqlserver/type.rb`
145+
- Core extensions: `lib/active_record/connection_adapters/sqlserver/core_ext/`
146+
147+
## Development Notes
148+
149+
### Environment Variables
150+
- `ARCONN`: Set to "sqlserver" for testing
151+
- `ARCONFIG`: Path to database configuration file
152+
- `TEST_FILES`: Comma-separated test files to run
153+
- `TEST_FILES_AR`: ActiveRecord test files to run
154+
- `ONLY_SQLSERVER`: Run only SQL Server-specific tests
155+
- `ONLY_ACTIVERECORD`: Run only ActiveRecord tests
156+
- `WARNING`: Enable Ruby warnings during tests
157+
158+
### Dependencies
159+
- **tiny_tds**: SQL Server connectivity (v3.0+)
160+
- **activerecord**: Rails ORM (v8.2.0.alpha)
161+
- **mocha**: Mocking framework for tests
162+
- **minitest**: Testing framework (v6.0+)
163+
164+
### Database Configuration
165+
- Tests use configuration from `test/config.yml`
166+
- Supports SQL Server 2012 and upward
167+
- Requires proper SQL Server connection setup
168+
169+
## Testing Best Practices
170+
171+
### Running Single Tests
172+
```bash
173+
# Run a single test file
174+
bundle exec ruby -Ilib:test test/cases/adapter_test_sqlserver.rb
175+
176+
# Run specific test method
177+
bundle exec ruby -Ilib:test test/cases/adapter_test_sqlserver.rb -n test_method_name
178+
```
179+
180+
### Test Environment Setup
181+
- All test helpers are in `test/support/`
182+
- Use `ARTest::SQLServer` module for test utilities
183+
- Schema loaded via `support/load_schema_sqlserver.rb`
184+
185+
### Test Data
186+
- Use fixtures where appropriate
187+
- Let blocks for test-specific data setup
188+
- Clean database state between tests

Dockerfile.ci

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ WORKDIR $WORKDIR
99

1010
COPY . $WORKDIR
1111

12-
RUN RAILS_BRANCH=main bundle install --jobs `expr $(cat /proc/cpuinfo | grep -c "cpu cores") - 1` --retry 3
12+
RUN RAILS_COMMIT=65dc2163e3 bundle install --jobs `expr $(cat /proc/cpuinfo | grep -c "cpu cores") - 1` --retry 3
1313

1414
CMD ["sh"]

Gemfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ gem "pg", "1.5.9"
1111
gem "sqlite3", ">= 2.1"
1212
gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby]
1313
gem "benchmark-ips"
14-
gem "minitest", ">= 5.15.0"
14+
gem "minitest", "~> 6.0"
15+
gem "minitest-mock"
1516
gem "msgpack", ">= 1.7.0"
1617

1718
if ENV["RAILS_SOURCE"]

compose.ci.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ services:
44
ci:
55
environment:
66
- ACTIVERECORD_UNITTEST_HOST=sqlserver
7-
- RAILS_BRANCH=main
7+
- RAILS_COMMIT=65dc2163e3
88
build:
99
context: .
1010
dockerfile: Dockerfile.ci
@@ -13,7 +13,7 @@ services:
1313
- "sqlserver"
1414
standardrb:
1515
environment:
16-
- RAILS_BRANCH=main
16+
- RAILS_COMMIT=65dc2163e3
1717
build:
1818
context: .
1919
dockerfile: Dockerfile.ci

lib/active_record/connection_adapters/sqlserver/database_statements.rb

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ def write_query?(sql) # :nodoc:
1313
!READ_QUERY.match?(sql.b)
1414
end
1515

16-
def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch:)
17-
unless binds.nil? || binds.empty?
18-
types, params = sp_executesql_types_and_parameters(binds)
19-
sql = sp_executesql_sql(sql, types, params, notification_payload[:name])
16+
def perform_query(raw_connection, intent)
17+
sql = intent.processed_sql
18+
19+
unless intent.binds.nil? || intent.binds.empty?
20+
types, params = sp_executesql_types_and_parameters(intent.binds)
21+
sql = sp_executesql_sql(intent.processed_sql, types, params, intent.notification_payload[:name])
2022
end
2123

2224
id_insert_table_name = query_requires_identity_insert?(sql)
@@ -30,8 +32,8 @@ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notif
3032
end
3133

3234
verified!
33-
notification_payload[:affected_rows] = affected_rows
34-
notification_payload[:row_count] = result.count
35+
intent.notification_payload[:affected_rows] = affected_rows
36+
intent.notification_payload[:row_count] = result.count
3537
result
3638
end
3739

@@ -62,14 +64,44 @@ def internal_exec_sql_query(sql, conn)
6264
finish_statement_handle(handle)
6365
end
6466

65-
def exec_delete(sql, name = nil, binds = [])
66-
sql = sql.dup << "; SELECT @@ROWCOUNT AS AffectedRows"
67-
super
67+
# Executes the delete statement and returns the number of rows affected.
68+
def delete(arel, name = nil, binds = [])
69+
# Clear query cache if the connection pool is configured to do so.
70+
if pool.dirties_query_cache
71+
ActiveRecord::Base.clear_query_caches_for_current_thread
72+
end
73+
74+
intent = QueryIntent.new(arel: arel, name: name, binds: binds)
75+
76+
# Compile Arel to get SQL
77+
compile_arel_in_intent(intent)
78+
79+
# Add `SELECT @@ROWCOUNT` to the end of the SQL to get the number of affected rows. This is needed because SQL Server does not return the number of affected rows in the same way as other databases.
80+
sql = intent.processed_sql.present? ? intent.processed_sql : intent.raw_sql
81+
ensure_writes_are_allowed(sql) if write_query?(sql)
82+
intent.processed_sql = "#{sql}; SELECT @@ROWCOUNT AS AffectedRows"
83+
84+
affected_rows(raw_execute(intent))
6885
end
6986

70-
def exec_update(sql, name = nil, binds = [])
71-
sql = sql.dup << "; SELECT @@ROWCOUNT AS AffectedRows"
72-
super
87+
# Executes the update statement and returns the number of rows affected.
88+
def update(arel, name = nil, binds = [])
89+
# Clear query cache if the connection pool is configured to do so.
90+
if pool.dirties_query_cache
91+
ActiveRecord::Base.clear_query_caches_for_current_thread
92+
end
93+
94+
intent = QueryIntent.new(arel: arel, name: name, binds: binds)
95+
96+
# Compile Arel to get SQL
97+
compile_arel_in_intent(intent)
98+
99+
# Add `SELECT @@ROWCOUNT` to the end of the SQL to get the number of affected rows. This is needed because SQL Server does not return the number of affected rows in the same way as other databases.
100+
sql = intent.processed_sql.present? ? intent.processed_sql : intent.raw_sql
101+
ensure_writes_are_allowed(sql) if write_query?(sql)
102+
intent.processed_sql = "#{sql}; SELECT @@ROWCOUNT AS AffectedRows"
103+
104+
affected_rows(raw_execute(intent))
73105
end
74106

75107
def begin_db_transaction
@@ -237,9 +269,11 @@ def execute_procedure(proc_name, *variables)
237269
end.join(", ")
238270
sql = "EXEC #{proc_name} #{vars}".strip
239271

240-
log(sql, "Execute Procedure") do |notification_payload|
272+
intent = QueryIntent.new(processed_sql: sql)
273+
274+
log(intent, "Execute Procedure") do |notification_payload|
241275
with_raw_connection do |conn|
242-
result = internal_raw_execute(sql, conn)
276+
result = internal_raw_execute(intent.processed_sql, conn)
243277
verified!
244278
options = {as: :hash, cache_rows: true, timezone: ActiveRecord.default_timezone || :utc}
245279

@@ -615,7 +649,7 @@ def joining_on_columns_with_uniqueness_constraints(columns_with_uniqueness_const
615649
# however, since we need to have "target." for the assignment, we also generate the CASE switch ourselves
616650
def build_sql_for_recording_timestamps_when_updating(insert:)
617651
insert.model.timestamp_attributes_for_update_in_model.filter_map do |column_name|
618-
if insert.send(:touch_timestamp_attribute?, column_name)
652+
if insert.send(:touch_timestamp_attribute?, column_name) && !column_name.to_sym.in?(insert.send(:insert_all).updatable_columns)
619653
"target.#{quote_column_name(column_name)}=CASE WHEN (#{insert.updatable_columns.map { |column| "(source.#{quote_column_name(column)} = target.#{quote_column_name(column)} OR (source.#{quote_column_name(column)} IS NULL AND target.#{quote_column_name(column)} IS NULL))" }.join(" AND ")}) THEN target.#{quote_column_name(column_name)} ELSE #{high_precision_current_timestamp} END,"
620654
end
621655
end.join

lib/active_record/connection_adapters/sqlserver/schema_statements.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ def drop_table(*table_names, **options)
3333

3434
def indexes(table_name)
3535
data = begin
36-
select("EXEC sp_helpindex #{quote(table_name)}", "SCHEMA")
36+
intent = QueryIntent.new(raw_sql: "EXEC sp_helpindex #{quote(table_name)}", name: "SCHEMA")
37+
select(intent)
3738
rescue
3839
[]
3940
end

lib/active_record/connection_adapters/sqlserver/showplan.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ def with_showplan_on
3030
end
3131

3232
def set_showplan_option(enable = true)
33-
sql = "SET #{showplan_option} #{enable ? "ON" : "OFF"}"
34-
raw_execute(sql, "SCHEMA")
33+
intent = QueryIntent.new(raw_sql: "SET #{showplan_option} #{enable ? "ON" : "OFF"}", name: "SCHEMA")
34+
raw_execute(intent)
3535
rescue
3636
raise ActiveRecordError, "#{showplan_option} could not be turned #{enable ? "ON" : "OFF"}, perhaps you do not have SHOWPLAN permissions?"
3737
end

test/cases/coerced_tests.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2704,12 +2704,12 @@ def test_assert_queries_match_coerced
27042704
error = assert_raises(Minitest::Assertion) {
27052705
assert_queries_match(/ASC OFFSET 0 ROWS FETCH NEXT @0 ROWS ONLY/i, count: 2) { Post.first }
27062706
}
2707-
assert_match(/1 instead of 2 queries/, error.message)
2707+
assert_match(/1 instead of 2 matching queries/, error.message)
27082708

27092709
error = assert_raises(Minitest::Assertion) {
27102710
assert_queries_match(/ASC OFFSET 0 ROWS FETCH NEXT @0 ROWS ONLY/i, count: 0) { Post.first }
27112711
}
2712-
assert_match(/1 instead of 0 queries/, error.message)
2712+
assert_match(/1 instead of 0 matching queries/, error.message)
27132713
end
27142714
end
27152715
end

test/support/core_ext/query_cache.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ module SqlIgnoredCache
2323
def cache_sql(sql, name, binds)
2424
result = super
2525

26-
@query_cache.instance_variable_get(:@map).delete_if do |cache_key, _v|
26+
query_cache.instance_variable_get(:@map).delete_if do |cache_key, _v|
2727
# Query cache key generated by `sql` or `[sql, binds]`, so need to retrieve `sql` for both cases.
2828
cache_key_sql = Array(cache_key).first
2929
Regexp.union(IGNORED_SQL).match?(cache_key_sql)

test/support/query_assertions.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ def assert_queries_count(count = nil, include_schema: false, &block)
1414
# End of monkey-patch
1515

1616
if count
17-
assert_equal count, queries.size, "#{queries.size} instead of #{count} queries were executed. Queries: #{queries.join("\n\n")}"
17+
assert_equal count, queries.size, "#{queries.size} instead of #{count} queries were executed#{". Queries:\n\n#{queries.join("\n\n")}" unless queries.empty?}"
1818
else
19-
assert_operator queries.size, :>=, 1, "1 or more queries expected, but none were executed.#{"\nQueries:\n#{queries.join("\n")}" unless queries.empty?}"
19+
assert_operator queries.size, :>=, 1, "1 or more queries expected, but none were executed"
2020
end
2121
result
2222
end

0 commit comments

Comments
 (0)