Skip to content

Commit eb734d8

Browse files
committed
Add comprehensive LOB callback spec for prepared vs unprepared statements
This spec clearly demonstrates the critical difference between LOB handling with prepared statements (bind parameters) vs without (raw SQL with empty_clob()). Key points demonstrated: - With prepared_statements: true, LOB data flows through type_cast() which creates OCI8::CLOB temp LOBs. Data is populated BEFORE INSERT. - With prepared_statements: false, SQL contains empty_clob() literals. The write_lobs callback is REQUIRED to populate LOB data after INSERT. This test suite will FAIL if the lob.rb callbacks are removed, proving they are necessary for backwards compatibility with prepared_statements: false. Related to: rsim#2483
1 parent e3e858a commit eb734d8

File tree

1 file changed

+301
-0
lines changed

1 file changed

+301
-0
lines changed
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
# frozen_string_literal: true
2+
3+
# This spec demonstrates the critical difference between LOB handling with
4+
# prepared statements (bind parameters) vs without (raw SQL with empty_clob()).
5+
#
6+
# BACKGROUND:
7+
# - With prepared_statements: true, LOB data is bound as OCI8::CLOB/BLOB
8+
# temporary LOBs which are populated BEFORE the INSERT executes.
9+
# - With prepared_statements: false, the SQL contains empty_clob()/empty_blob()
10+
# literals, and the LOB data MUST be written via a subsequent SELECT FOR UPDATE
11+
# + write operation (the write_lobs callback).
12+
#
13+
# IMPORTANT: The write_lobs callback in lob.rb is REQUIRED for the non-prepared
14+
# case. Removing it will cause data loss when prepared_statements is disabled.
15+
#
16+
# See: https://github.com/rsim/oracle-enhanced/pull/2483 for context.
17+
18+
describe "OracleEnhancedAdapter LOB callbacks: prepared vs unprepared statements" do
19+
include SchemaSpecHelper
20+
21+
before(:all) do
22+
ActiveRecord::Base.establish_connection(CONNECTION_PARAMS)
23+
@conn = ActiveRecord::Base.connection
24+
schema_define do
25+
create_table :test_lob_callbacks, force: true do |t|
26+
t.string :name, limit: 50
27+
t.text :clob_content
28+
t.binary :blob_content
29+
end
30+
end
31+
32+
class ::TestLobCallback < ActiveRecord::Base
33+
self.table_name = "test_lob_callbacks"
34+
end
35+
end
36+
37+
after(:all) do
38+
@conn.drop_table :test_lob_callbacks, if_exists: true
39+
Object.send(:remove_const, "TestLobCallback")
40+
ActiveRecord::Base.clear_cache!
41+
end
42+
43+
after(:each) do
44+
TestLobCallback.delete_all
45+
end
46+
47+
# Generate test data of specific sizes
48+
def clob_data(size_kb)
49+
"x" * (size_kb * 1024)
50+
end
51+
52+
def blob_data(size_kb)
53+
Random.new(42).bytes(size_kb * 1024)
54+
end
55+
56+
describe "with prepared_statements ENABLED (default)" do
57+
# This is the common case. LOB data flows through type_cast() which creates
58+
# OCI8::CLOB.new(connection, data) - a temporary LOB with data already in it.
59+
# Oracle copies this temp LOB into the table column during INSERT.
60+
61+
it "creates record with small CLOB data" do
62+
record = TestLobCallback.create!(name: "small", clob_content: "Hello World")
63+
record.reload
64+
expect(record.clob_content).to eq("Hello World")
65+
end
66+
67+
it "creates record with large CLOB data (100KB)" do
68+
data = clob_data(100)
69+
record = TestLobCallback.create!(name: "large_clob", clob_content: data)
70+
record.reload
71+
expect(record.clob_content).to eq(data)
72+
expect(record.clob_content.bytesize).to eq(100 * 1024)
73+
end
74+
75+
it "creates record with very large CLOB data (1MB)" do
76+
data = clob_data(1024)
77+
record = TestLobCallback.create!(name: "very_large_clob", clob_content: data)
78+
record.reload
79+
expect(record.clob_content).to eq(data)
80+
expect(record.clob_content.bytesize).to eq(1024 * 1024)
81+
end
82+
83+
it "creates record with BLOB data" do
84+
data = blob_data(10)
85+
record = TestLobCallback.create!(name: "blob", blob_content: data)
86+
record.reload
87+
expect(record.blob_content).to eq(data)
88+
end
89+
90+
it "creates record with large BLOB data (512KB)" do
91+
data = blob_data(512)
92+
record = TestLobCallback.create!(name: "large_blob", blob_content: data)
93+
record.reload
94+
expect(record.blob_content).to eq(data)
95+
expect(record.blob_content.bytesize).to eq(512 * 1024)
96+
end
97+
98+
it "updates record with CLOB data" do
99+
record = TestLobCallback.create!(name: "update_test")
100+
record.clob_content = clob_data(50)
101+
record.save!
102+
record.reload
103+
expect(record.clob_content.bytesize).to eq(50 * 1024)
104+
end
105+
end
106+
107+
describe "with prepared_statements DISABLED" do
108+
# This is the critical case that requires the write_lobs callback.
109+
# Without bind parameters, the SQL contains empty_clob() literals.
110+
# The after_create/after_update callbacks MUST populate the LOB data.
111+
#
112+
# If you remove the lob.rb callbacks, these tests will FAIL:
113+
# - CLOB columns will be empty strings
114+
# - BLOB columns will be empty
115+
116+
around(:each) do |example|
117+
old_prepared_statements = @conn.prepared_statements
118+
@conn.instance_variable_set(:@prepared_statements, false)
119+
begin
120+
example.run
121+
ensure
122+
@conn.instance_variable_set(:@prepared_statements, old_prepared_statements)
123+
end
124+
end
125+
126+
context "CLOB creation (REQUIRES write_lobs callback)" do
127+
it "creates record with small CLOB data" do
128+
record = TestLobCallback.create!(name: "small_unprepared", clob_content: "Hello World")
129+
record.reload
130+
# WITHOUT write_lobs callback, this would be "" (empty string)
131+
expect(record.clob_content).to eq("Hello World")
132+
end
133+
134+
it "creates record with medium CLOB data (10KB)" do
135+
data = clob_data(10)
136+
record = TestLobCallback.create!(name: "medium_clob_unprepared", clob_content: data)
137+
record.reload
138+
# WITHOUT write_lobs callback, this would be "" (empty string)
139+
expect(record.clob_content).to eq(data)
140+
expect(record.clob_content.bytesize).to eq(10 * 1024)
141+
end
142+
143+
it "creates record with large CLOB data (100KB)" do
144+
data = clob_data(100)
145+
record = TestLobCallback.create!(name: "large_clob_unprepared", clob_content: data)
146+
record.reload
147+
# WITHOUT write_lobs callback, this would be "" (empty string)
148+
expect(record.clob_content).to eq(data)
149+
expect(record.clob_content.bytesize).to eq(100 * 1024)
150+
end
151+
152+
it "creates record with very large CLOB data (1MB)" do
153+
data = clob_data(1024)
154+
record = TestLobCallback.create!(name: "very_large_clob_unprepared", clob_content: data)
155+
record.reload
156+
# WITHOUT write_lobs callback, this would be "" (empty string)
157+
expect(record.clob_content).to eq(data)
158+
expect(record.clob_content.bytesize).to eq(1024 * 1024)
159+
end
160+
161+
it "creates record with empty CLOB" do
162+
record = TestLobCallback.create!(name: "empty_clob_unprepared", clob_content: "")
163+
record.reload
164+
expect(record.clob_content).to eq("")
165+
end
166+
end
167+
168+
context "BLOB creation (REQUIRES write_lobs callback)" do
169+
it "creates record with small BLOB data" do
170+
data = blob_data(1)
171+
record = TestLobCallback.create!(name: "small_blob_unprepared", blob_content: data)
172+
record.reload
173+
# WITHOUT write_lobs callback, this would be empty/nil
174+
expect(record.blob_content).to eq(data)
175+
end
176+
177+
it "creates record with large BLOB data (100KB)" do
178+
data = blob_data(100)
179+
record = TestLobCallback.create!(name: "large_blob_unprepared", blob_content: data)
180+
record.reload
181+
# WITHOUT write_lobs callback, this would be empty/nil
182+
expect(record.blob_content).to eq(data)
183+
expect(record.blob_content.bytesize).to eq(100 * 1024)
184+
end
185+
end
186+
187+
context "CLOB updates (REQUIRES write_lobs callback)" do
188+
it "updates record with CLOB data from nil" do
189+
record = TestLobCallback.create!(name: "update_from_nil_unprepared")
190+
record.reload
191+
expect(record.clob_content).to be_nil
192+
193+
record.clob_content = clob_data(50)
194+
record.save!
195+
record.reload
196+
# WITHOUT write_lobs callback, this would remain nil or be empty
197+
expect(record.clob_content.bytesize).to eq(50 * 1024)
198+
end
199+
200+
it "updates record with CLOB data from existing data" do
201+
original_data = "original content"
202+
record = TestLobCallback.create!(name: "update_existing_unprepared", clob_content: original_data)
203+
record.reload
204+
expect(record.clob_content).to eq(original_data)
205+
206+
new_data = clob_data(25)
207+
record.clob_content = new_data
208+
record.save!
209+
record.reload
210+
# WITHOUT write_lobs callback, this would still be the original data or empty
211+
expect(record.clob_content).to eq(new_data)
212+
end
213+
end
214+
end
215+
216+
describe "SQL generation verification" do
217+
# These tests verify what SQL is generated in each mode,
218+
# demonstrating why the callback is necessary.
219+
220+
it "uses bind parameters with prepared_statements enabled" do
221+
# When prepared_statements is true, LOB values go through type_cast()
222+
# which converts them to OCI8::CLOB objects (temp LOBs)
223+
expect(@conn.prepared_statements).to be true
224+
225+
sql_log = []
226+
callback = ->(name, start, finish, id, payload) { sql_log << payload[:sql] }
227+
228+
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
229+
TestLobCallback.create!(name: "prepared_test", clob_content: "test data")
230+
end
231+
232+
insert_sql = sql_log.find { |s| s.include?("INSERT") }
233+
# With prepared statements, SQL has bind placeholders, not literals
234+
expect(insert_sql).to include(":a1") # bind placeholder
235+
expect(insert_sql).not_to include("empty_clob()")
236+
end
237+
238+
it "uses empty_clob() literal with prepared_statements disabled" do
239+
old_prepared_statements = @conn.prepared_statements
240+
@conn.instance_variable_set(:@prepared_statements, false)
241+
242+
sql_log = []
243+
callback = ->(name, start, finish, id, payload) { sql_log << payload[:sql] }
244+
245+
begin
246+
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
247+
TestLobCallback.create!(name: "unprepared_test", clob_content: "test data")
248+
end
249+
250+
insert_sql = sql_log.find { |s| s.include?("INSERT") }
251+
# Without prepared statements, SQL has empty_clob() literal
252+
expect(insert_sql).to include("empty_clob()")
253+
254+
# And there should be a SELECT FOR UPDATE to write the LOB data
255+
lob_write_sql = sql_log.find { |s| s.include?("FOR UPDATE") }
256+
expect(lob_write_sql).to be_present
257+
ensure
258+
@conn.instance_variable_set(:@prepared_statements, old_prepared_statements)
259+
end
260+
end
261+
end
262+
263+
describe "edge cases and limitations" do
264+
around(:each) do |example|
265+
old_prepared_statements = @conn.prepared_statements
266+
@conn.instance_variable_set(:@prepared_statements, false)
267+
begin
268+
example.run
269+
ensure
270+
@conn.instance_variable_set(:@prepared_statements, old_prepared_statements)
271+
end
272+
end
273+
274+
context "inline CLOB quoting limitations (for reference)" do
275+
# These tests document the limitations of inline LOB quoting approaches
276+
# like to_clob(varchar2_chunks). The write_lobs callback doesn't have
277+
# these limitations because it uses the LOB locator directly.
278+
279+
it "handles CLOB content with special characters" do
280+
data = "Line 1\nLine 2\r\nLine 3\tTabbed\0NullByte"
281+
record = TestLobCallback.create!(name: "special_chars", clob_content: data)
282+
record.reload
283+
expect(record.clob_content).to eq(data)
284+
end
285+
286+
it "handles CLOB content with unicode" do
287+
data = "Hello 世界 🌍 Ωmega"
288+
record = TestLobCallback.create!(name: "unicode", clob_content: data)
289+
record.reload
290+
expect(record.clob_content).to eq(data)
291+
end
292+
293+
it "handles CLOB content with single quotes" do
294+
data = "It's a test with 'quoted' content and ''double quotes''"
295+
record = TestLobCallback.create!(name: "quotes", clob_content: data)
296+
record.reload
297+
expect(record.clob_content).to eq(data)
298+
end
299+
end
300+
end
301+
end

0 commit comments

Comments
 (0)