diff --git a/.github/workflows/ado-net-tests.yml b/.github/workflows/ado-net-tests.yml new file mode 100644 index 00000000..9092004f --- /dev/null +++ b/.github/workflows/ado-net-tests.yml @@ -0,0 +1,30 @@ +on: + push: + branches: [ main ] + pull_request: +permissions: + contents: read + pull-requests: write +name: ADO.NET Tests +jobs: + units: + strategy: + matrix: + dotnet-version: ['8.0.x', '9.0.x'] + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Install dotnet + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ matrix.dotnet-version }} + - name: Checkout code + uses: actions/checkout@v4 + - name: spanner-ado-net-tests + working-directory: drivers/spanner-ado-net/spanner-ado-net-tests + run: dotnet test --verbosity normal + shell: bash + - name: spanner-ado-net-specification-tests + working-directory: drivers/spanner-ado-net/spanner-ado-net-specification-tests + run: dotnet test --verbosity normal + shell: bash diff --git a/drivers/spanner-ado-net/.gitignore b/drivers/spanner-ado-net/.gitignore new file mode 100644 index 00000000..808112cd --- /dev/null +++ b/drivers/spanner-ado-net/.gitignore @@ -0,0 +1,4 @@ +.idea +obj +bin +*DotSettings.user diff --git a/drivers/spanner-ado-net/LICENSE b/drivers/spanner-ado-net/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/drivers/spanner-ado-net/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/drivers/spanner-ado-net/README.md b/drivers/spanner-ado-net/README.md new file mode 100644 index 00000000..fbcfda13 --- /dev/null +++ b/drivers/spanner-ado-net/README.md @@ -0,0 +1,5 @@ +# Spanner ADO.NET Data Provider + +ADO.NET Data Provider for Spanner. + +__ALPHA: Not for production use__ diff --git a/drivers/spanner-ado-net/global.json b/drivers/spanner-ado-net/global.json new file mode 100644 index 00000000..2ddda36c --- /dev/null +++ b/drivers/spanner-ado-net/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/README.md b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/README.md new file mode 100644 index 00000000..e7015b23 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/README.md @@ -0,0 +1,5 @@ +# Spanner ADO.NET Data Provider Benchmarks + +Benchmarks for the ADO.NET Data Provider for Spanner. + +__ALPHA: Not for production use__ diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/deploy.txt b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/deploy.txt new file mode 100644 index 00000000..b08e5498 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/deploy.txt @@ -0,0 +1,46 @@ + +gcloud run deploy spannerlib-dotnet-benchmark-tpcc \ + --region=europe-north1 \ + --no-allow-unauthenticated --no-cpu-throttling \ + --min-instances=1 --max-instances=1 \ + --cpu=4 --memory=2Gi \ + --set-env-vars=NUM_WAREHOUSES=100,TRANSACTIONS_PER_SECOND=50,NUM_CLIENTS=50 \ + --base-image dotnet8 \ + --source . + +gcloud run deploy spannerlib-dotnet-benchmark-tpcc \ + --region=europe-north1 \ + --no-allow-unauthenticated --no-cpu-throttling \ + --min-instances=1 --max-instances=1 \ + --cpu=4 --memory=2Gi \ + --set-env-vars=NUM_WAREHOUSES=100,TRANSACTIONS_PER_SECOND=50,NUM_CLIENTS=50,RETRY_ABORTS_INTERNALLY=false \ + --base-image dotnet8 \ + --source . + + +gcloud run deploy spannerlib-dotnet-benchmark-tpcc \ + --region=europe-north1 \ + --no-allow-unauthenticated --no-cpu-throttling \ + --min-instances=1 --max-instances=1 \ + --cpu=4 --memory=2Gi \ + --set-env-vars=NUM_WAREHOUSES=100,CLIENT_TYPE=ClientLib,TRANSACTIONS_PER_SECOND=50,NUM_CLIENTS=50 \ + --base-image dotnet8 \ + --source . + +gcloud run deploy spannerlib-dotnet-benchmark-tpcc \ + --region=europe-north1 \ + --no-allow-unauthenticated --no-cpu-throttling \ + --min-instances=1 --max-instances=1 \ + --cpu=4 --memory=2Gi \ + --set-env-vars=NUM_WAREHOUSES=100,CLIENT_TYPE=NativeSpannerLib,TRANSACTIONS_PER_SECOND=50,NUM_CLIENTS=50 \ + --base-image dotnet8 \ + --source . + +gcloud run deploy spannerlib-dotnet-benchmark-tpcc \ + --region=europe-north1 \ + --no-allow-unauthenticated --no-cpu-throttling \ + --min-instances=1 --max-instances=1 \ + --cpu=4 --memory=2Gi \ + --set-env-vars=NUM_WAREHOUSES=100,CLIENT_TYPE=NativeSpannerLib,TRANSACTIONS_PER_SECOND=50,NUM_CLIENTS=50,RETRY_ABORTS_INTERNALLY=false \ + --base-image dotnet8 \ + --source . diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/spanner-ado-net-benchmarks.csproj b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/spanner-ado-net-benchmarks.csproj new file mode 100644 index 00000000..1c8e0a3d --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/spanner-ado-net-benchmarks.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + Google.Cloud.Spanner.DataProvider.Benchmarks + enable + enable + Google.Cloud.Spanner.DataProvider.Benchmarks + default + + + + + + + + + diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/LastNameGenerator.cs b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/LastNameGenerator.cs new file mode 100644 index 00000000..2179eb61 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/LastNameGenerator.cs @@ -0,0 +1,22 @@ +namespace Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc; + +public static class LastNameGenerator +{ + private static readonly string[] Parts = ["BAR", "OUGHT", "ABLE", "PRI", "PRES", "ESE", "ANTI", "CALLY", "ATION", "EING"]; + + public static string GenerateLastName(long rowIndex) { + int row; + if (rowIndex < 1000L) + { + row = (int) rowIndex; + } + else + { + row = Random.Shared.Next(1000); + } + return Parts[row / 100] + + Parts[row / 10 % 10] + + Parts[row % 10]; + } + +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/Program.cs b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/Program.cs new file mode 100644 index 00000000..62e0d7d1 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/Program.cs @@ -0,0 +1,158 @@ +using System.Collections.Concurrent; +using System.Data.Common; +using System.Diagnostics; +using Google.Cloud.Spanner.Admin.Database.V1; +using Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc.loader; +using Microsoft.AspNetCore.Builder; + +namespace Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc; + +public static class Program +{ + enum ClientType + { + SpannerLib, + NativeSpannerLib, + ClientLib, + } + + public static async Task Main(string[] args) + { + var cancellationTokenSource = new CancellationTokenSource(); + var builder = WebApplication.CreateBuilder(args); + var port = Environment.GetEnvironmentVariable("PORT") ?? "8080"; + var url = $"http://0.0.0.0:{port}"; + var app = builder.Build(); + app.MapGet("/", () => { }); + var webapp = app.RunAsync(url); + + var logWaitTime = int.Parse(Environment.GetEnvironmentVariable("LOG_WAIT_TIME") ?? "10"); + var database = Environment.GetEnvironmentVariable("DATABASE") ?? "projects/appdev-soda-spanner-staging/instances/knut-test-ycsb/databases/dotnet-tpcc"; + var retryAbortsInternally = bool.Parse(Environment.GetEnvironmentVariable("RETRY_ABORTS_INTERNALLY") ?? "true"); + var numWarehouses = int.Parse(Environment.GetEnvironmentVariable("NUM_WAREHOUSES") ?? "10"); + var numClients = int.Parse(Environment.GetEnvironmentVariable("NUM_CLIENTS") ?? "10"); + var targetTps = int.Parse(Environment.GetEnvironmentVariable("TRANSACTIONS_PER_SECOND") ?? "0"); + var clientTypeName = Environment.GetEnvironmentVariable("CLIENT_TYPE") ?? "SpannerLib"; + if (!Enum.TryParse(clientTypeName, out ClientType clientType)) + { + throw new ArgumentException($"Unknown client type: {clientTypeName}"); + } + + var connectionString = $"Data Source={database}"; + if (!retryAbortsInternally) + { + connectionString += ";retryAbortsInternally=false"; + } + await using (var connection = new SpannerConnection()) + { + connection.ConnectionString = connectionString; + await connection.OpenAsync(cancellationTokenSource.Token); + + Console.WriteLine("Creating schema..."); + await SchemaUtil.CreateSchemaAsync(connection, DatabaseDialect.Postgresql, cancellationTokenSource.Token); + + Console.WriteLine("Loading data..."); + var loader = new DataLoader(connection, numWarehouses); + await loader.LoadAsync(cancellationTokenSource.Token); + } + + Console.WriteLine("Running benchmark..."); + var stats = new Stats(); + + if (targetTps > 0) + { + var maxWaitTime = 2 * 1000 / targetTps; + Console.WriteLine($"Clients: {numClients}"); + Console.WriteLine($"Transactions per second: {targetTps}"); + Console.WriteLine($"Max wait time: {maxWaitTime}"); + var runners = new BlockingCollection(); + for (var client = 0; client < numClients; client++) + { + runners.Add(await CreateRunnerAsync(clientType, connectionString, stats, numWarehouses, cancellationTokenSource), cancellationTokenSource.Token); + } + var lastLogTime = DateTime.UtcNow; + while (!cancellationTokenSource.IsCancellationRequested) + { + var randomWaitTime = Random.Shared.Next(0, maxWaitTime); + var stopwatch = Stopwatch.StartNew(); + if (runners.TryTake(out var runner, 20_000, cancellationTokenSource.Token)) + { + var source = new CancellationTokenSource(); + source.CancelAfter(TimeSpan.FromSeconds(10)); + var token = source.Token; + stats.RegisterTransactionStarted(); + var task = runner!.RunTransactionAsync(token); + _ = task.ContinueWith(_ => + { + stats.RegisterTransactionCompleted(); + runners.Add(runner, cancellationTokenSource.Token); + task.Dispose(); + }, TaskContinuationOptions.ExecuteSynchronously); + } + else + { + await Console.Error.WriteLineAsync("No runner available"); + } + randomWaitTime -= (int) stopwatch.ElapsedMilliseconds; + if (randomWaitTime > 0) + { + await Task.Delay(TimeSpan.FromMilliseconds(randomWaitTime), cancellationTokenSource.Token); + } + if ((DateTime.UtcNow - lastLogTime).TotalSeconds >= logWaitTime) + { + Console.WriteLine($"Num available runners: {runners.Count}"); + Console.WriteLine($"Thread pool size: {ThreadPool.ThreadCount}"); + stats.LogStats(); + lastLogTime = DateTime.UtcNow; + } + } + } + else + { + var tasks = new List(); + for (var client = 0; client < numClients; client++) + { + var runner = await CreateRunnerAsync(clientType, connectionString, stats, numWarehouses, cancellationTokenSource); + tasks.Add(runner.RunAsync(cancellationTokenSource.Token)); + } + while (!cancellationTokenSource.Token.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(logWaitTime), cancellationTokenSource.Token); + stats.LogStats(); + } + await Task.WhenAll(tasks); + } + + await app.StopAsync(cancellationTokenSource.Token); + await webapp; + } + + private static async Task CreateRunnerAsync( + ClientType clientType, + string connectionString, + Stats stats, + int numWarehouses, + CancellationTokenSource cancellationTokenSource) + { + DbConnection connection; + if (clientType == ClientType.SpannerLib) + { + connection = new SpannerConnection(); + } + else if (clientType == ClientType.NativeSpannerLib) + { + connection = new SpannerConnection {UseNativeLibrary = true}; + } + else if (clientType == ClientType.ClientLib) + { + connection = new Google.Cloud.Spanner.Data.SpannerConnection(); + } + else + { + throw new ArgumentException($"Unknown client type: {clientType}"); + } + connection.ConnectionString = connectionString; + await connection.OpenAsync(cancellationTokenSource.Token); + return new TpccRunner(stats, connection, numWarehouses); + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/RowNotFoundException.cs b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/RowNotFoundException.cs new file mode 100644 index 00000000..f1b3530b --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/RowNotFoundException.cs @@ -0,0 +1,5 @@ +using System.Data.Common; + +namespace Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc; + +public class RowNotFoundException(string message) : DbException(message); \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/SchemaDefinition.cs b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/SchemaDefinition.cs new file mode 100644 index 00000000..dff383ea --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/SchemaDefinition.cs @@ -0,0 +1,200 @@ +namespace Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc; + +public static class SchemaDefinition +{ + public const string CreateTablesPostgreSql = @" +START BATCH DDL; + +CREATE TABLE IF NOT EXISTS warehouse ( + w_id int not null, + w_name varchar(10), + w_street_1 varchar(20), + w_street_2 varchar(20), + w_city varchar(20), + w_state varchar(2), + w_zip varchar(9), + w_tax decimal, + w_ytd decimal, + primary key (w_id) +); + +create table IF NOT EXISTS district ( + d_id int not null, + w_id int not null, + d_name varchar(10), + d_street_1 varchar(20), + d_street_2 varchar(20), + d_city varchar(20), + d_state varchar(2), + d_zip varchar(9), + d_tax decimal, + d_ytd decimal, + d_next_o_id int, + primary key (w_id, d_id) +); + +-- CUSTOMER TABLE + +create table IF NOT EXISTS customer ( + c_id int not null, + d_id int not null, + w_id int not null, + c_first varchar(16), + c_middle varchar(2), + c_last varchar(16), + c_street_1 varchar(20), + c_street_2 varchar(20), + c_city varchar(20), + c_state varchar(2), + c_zip varchar(9), + c_phone varchar(16), + c_since timestamptz, + c_credit varchar(2), + c_credit_lim bigint, + c_discount decimal, + c_balance decimal, + c_ytd_payment decimal, + c_payment_cnt int, + c_delivery_cnt int, + c_data text, + PRIMARY KEY(w_id, d_id, c_id) +); + +-- HISTORY TABLE + +create table IF NOT EXISTS history ( + c_id int, + d_id int, + w_id int, + h_d_id int, + h_w_id int, + h_date timestamptz, + h_amount decimal, + h_data varchar(24), + PRIMARY KEY(c_id, d_id, w_id, h_d_id, h_w_id, h_date) +); + +create table IF NOT EXISTS orders ( + o_id int not null, + d_id int not null, + w_id int not null, + c_id int not null, + o_entry_d timestamptz, + o_carrier_id int, + o_ol_cnt int, + o_all_local int, + PRIMARY KEY(w_id, d_id, c_id, o_id) +); + +-- NEW_ORDER table + +create table IF NOT EXISTS new_orders ( + o_id int not null, + c_id int not null, + d_id int not null, + w_id int not null, + PRIMARY KEY(w_id, d_id, o_id, c_id) +); + +create table IF NOT EXISTS order_line ( + o_id int not null, + c_id int not null, + d_id int not null, + w_id int not null, + ol_number int not null, + ol_i_id int, + ol_supply_w_id int, + ol_delivery_d timestamptz, + ol_quantity int, + ol_amount decimal, + ol_dist_info varchar(24), + PRIMARY KEY(w_id, d_id, o_id, c_id, ol_number) +); + +-- STOCK table + +create table IF NOT EXISTS stock ( + s_i_id int not null, + w_id int not null, + s_quantity int, + s_dist_01 varchar(24), + s_dist_02 varchar(24), + s_dist_03 varchar(24), + s_dist_04 varchar(24), + s_dist_05 varchar(24), + s_dist_06 varchar(24), + s_dist_07 varchar(24), + s_dist_08 varchar(24), + s_dist_09 varchar(24), + s_dist_10 varchar(24), + s_ytd decimal, + s_order_cnt int, + s_remote_cnt int, + s_data varchar(50), + PRIMARY KEY(w_id, s_i_id) +); + +create table IF NOT EXISTS item ( + i_id int not null, + i_im_id int, + i_name varchar(24), + i_price decimal, + i_data varchar(50), + PRIMARY KEY(i_id) +); + +CREATE INDEX idx_customer ON customer (w_id,d_id,c_last,c_first); +CREATE INDEX idx_orders ON orders (w_id,d_id,o_id); +CREATE INDEX fkey_stock_2 ON stock (s_i_id); +CREATE INDEX fkey_order_line_2 ON order_line (ol_supply_w_id,ol_i_id); +CREATE INDEX fkey_history_1 ON history (w_id,d_id,c_id); +CREATE INDEX fkey_history_2 ON history (h_w_id,h_d_id ); + +ALTER TABLE new_orders ADD CONSTRAINT fkey_new_orders_1_ FOREIGN KEY(w_id,d_id,c_id,o_id) REFERENCES orders(w_id,d_id,c_id,o_id); +ALTER TABLE orders ADD CONSTRAINT fkey_orders_1_ FOREIGN KEY(w_id,d_id,c_id) REFERENCES customer(w_id,d_id,c_id); +ALTER TABLE customer ADD CONSTRAINT fkey_customer_1_ FOREIGN KEY(w_id,d_id) REFERENCES district(w_id,d_id); +ALTER TABLE history ADD CONSTRAINT fkey_history_1_ FOREIGN KEY(w_id,d_id,c_id) REFERENCES customer(w_id,d_id,c_id); +ALTER TABLE history ADD CONSTRAINT fkey_history_2_ FOREIGN KEY(h_w_id,h_d_id) REFERENCES district(w_id,d_id); +ALTER TABLE district ADD CONSTRAINT fkey_district_1_ FOREIGN KEY(w_id) REFERENCES warehouse(w_id); +ALTER TABLE order_line ADD CONSTRAINT fkey_order_line_1_ FOREIGN KEY(w_id,d_id,c_id,o_id) REFERENCES orders(w_id,d_id,c_id,o_id); +ALTER TABLE order_line ADD CONSTRAINT fkey_order_line_2_ FOREIGN KEY(ol_supply_w_id,ol_i_id) REFERENCES stock(w_id,s_i_id); +ALTER TABLE stock ADD CONSTRAINT fkey_stock_1_ FOREIGN KEY(w_id) REFERENCES warehouse(w_id); +ALTER TABLE stock ADD CONSTRAINT fkey_stock_2_ FOREIGN KEY(s_i_id) REFERENCES item(i_id); + +RUN BATCH; +"; + + public const string DropTables = @" +start batch ddl; + +drop index if exists fkey_history_2; +drop index if exists fkey_history_1; +drop index if exists fkey_order_line_2; +drop index if exists fkey_stock_2; +drop index if exists idx_orders; +drop index if exists idx_customer; + +ALTER TABLE new_orders DROP CONSTRAINT fkey_new_orders_1_; +ALTER TABLE orders DROP CONSTRAINT fkey_orders_1_; +ALTER TABLE customer DROP CONSTRAINT fkey_customer_1_; +ALTER TABLE history DROP CONSTRAINT fkey_history_1_; +ALTER TABLE history DROP CONSTRAINT fkey_history_2_; +ALTER TABLE district DROP CONSTRAINT fkey_district_1_; +ALTER TABLE order_line DROP CONSTRAINT fkey_order_line_1_; +ALTER TABLE order_line DROP CONSTRAINT fkey_order_line_2_; +ALTER TABLE stock DROP CONSTRAINT fkey_stock_1_; +ALTER TABLE stock DROP CONSTRAINT fkey_stock_2_; + +drop table if exists new_orders; +drop table if exists order_line; +drop table if exists history; +drop table if exists orders; +drop table if exists stock; +drop table if exists customer; +drop table if exists district; +drop table if exists warehouse; +drop table if exists item; + +run batch; +"; +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/SchemaUtil.cs b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/SchemaUtil.cs new file mode 100644 index 00000000..49acdd8e --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/SchemaUtil.cs @@ -0,0 +1,47 @@ +using Google.Cloud.Spanner.Admin.Database.V1; + +namespace Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc; + +static class SchemaUtil +{ + internal static async Task CreateSchemaAsync(SpannerConnection connection, DatabaseDialect dialect, CancellationToken cancellationToken) + { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = "select count(1) " + + "from information_schema.tables " + + "where " + + (dialect == DatabaseDialect.Postgresql ? "table_schema='public' and " : "table_schema='' and ") + + "table_name in ('warehouse', 'district', 'customer', 'history', 'orders', 'new_orders', 'order_line', 'stock', 'item')"; + var count = await cmd.ExecuteScalarAsync(cancellationToken); + if (count is long and 9) + { + return; + } + + var commands = SchemaDefinition.CreateTablesPostgreSql.Split(";"); + foreach (var command in commands) + { + if (command.Trim() == "") + { + continue; + } + cmd.CommandText = command; + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + } + + internal static async Task DropSchemaAsync(SpannerConnection connection, CancellationToken cancellationToken) + { + await using var cmd = connection.CreateCommand(); + var commands = SchemaDefinition.DropTables.Split(";"); + foreach (var command in commands) + { + if (command.Trim() == "") + { + continue; + } + cmd.CommandText = command; + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/Stats.cs b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/Stats.cs new file mode 100644 index 00000000..56ab5e10 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/Stats.cs @@ -0,0 +1,97 @@ +namespace Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc; + +internal class Stats +{ + private readonly DateTime _startTime; + private ulong _numTransactions; + private ulong _numTransactionsStarted; + private ulong _numTransactionsCompleted; + private ulong _numFailedTransactions; + private ulong _numNewOrderTransactions; + private ulong _numPaymentTransactions; + private ulong _numOrderStatusTransactions; + private ulong _numDeliveryTransactions; + private ulong _numStockLevelTransactions; + private Exception? _lastException; + + private ulong _totalMillis; + + internal Stats() + { + _startTime = DateTime.UtcNow; + } + + internal void RegisterTransactionStarted() + { + Interlocked.Increment(ref _numTransactionsStarted); + } + + internal void RegisterTransactionCompleted() + { + Interlocked.Increment(ref _numTransactionsCompleted); + } + + internal void RegisterTransaction(TpccRunner.TransactionType transactionType, TimeSpan duration) + { + Interlocked.Increment(ref _numTransactions); + Interlocked.Add(ref _totalMillis, (ulong) duration.TotalMilliseconds); + switch (transactionType) + { + case TpccRunner.TransactionType.NewOrder: + Interlocked.Increment(ref _numNewOrderTransactions); + break; + case TpccRunner.TransactionType.Payment: + Interlocked.Increment(ref _numPaymentTransactions); + break; + case TpccRunner.TransactionType.OrderStatus: + Interlocked.Increment(ref _numOrderStatusTransactions); + break; + case TpccRunner.TransactionType.Delivery: + Interlocked.Increment(ref _numDeliveryTransactions); + break; + case TpccRunner.TransactionType.StockLevel: + Interlocked.Increment(ref _numStockLevelTransactions); + break; + default: + throw new ArgumentOutOfRangeException(nameof(transactionType), transactionType, null); + } + } + + internal void RegisterFailedTransaction(TpccRunner.TransactionType transactionType, TimeSpan duration, Exception error) + { + Interlocked.Increment(ref _numFailedTransactions); + lock (this) + { + _lastException = error; + } + } + + internal void LogStats() + { + lock (this) + { + if (_lastException != null) + { + Console.Error.WriteLine(_lastException); + _lastException = null; + } + } + Console.Write(ToString()); + } + + public override string ToString() + { + return $" Total duration: {DateTime.UtcNow - _startTime}{Environment.NewLine}" + + $"Transactions/sec: {Interlocked.Read(ref _numTransactions) / (DateTime.UtcNow - _startTime).TotalSeconds}{Environment.NewLine}" + + $" Total: {Interlocked.Read(ref _numTransactions)}{Environment.NewLine}" + + $" Avg: {Interlocked.Read(ref _totalMillis) / Interlocked.Read(ref _numTransactions)}{Environment.NewLine}" + + $" Started: {Interlocked.Read(ref _numTransactionsStarted)}{Environment.NewLine}" + + $" Completed: {Interlocked.Read(ref _numTransactionsCompleted)}{Environment.NewLine}" + + $" Failed: {Interlocked.Read(ref _numFailedTransactions)}{Environment.NewLine}" + + $" Num new order: {Interlocked.Read(ref _numNewOrderTransactions)}{Environment.NewLine}" + + $" Num payment: {Interlocked.Read(ref _numPaymentTransactions)}{Environment.NewLine}" + + $"Num order status: {Interlocked.Read(ref _numOrderStatusTransactions)}{Environment.NewLine}" + + $" Num delivery: {Interlocked.Read(ref _numDeliveryTransactions)}{Environment.NewLine}" + + $" Num stock level: {Interlocked.Read(ref _numStockLevelTransactions)}{Environment.NewLine}"; + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/TpccRunner.cs b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/TpccRunner.cs new file mode 100644 index 00000000..bf753df6 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/TpccRunner.cs @@ -0,0 +1,861 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Data; +using System.Data.Common; +using System.Diagnostics; +using Google.Cloud.Spanner.Data; +using Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc.loader; +using Google.Cloud.Spanner.V1; +using Google.Rpc; +using SpannerException = Google.Cloud.SpannerLib.SpannerException; + +namespace Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc; + +internal class TpccRunner +{ + internal enum TransactionType + { + Unknown, + NewOrder, + Payment, + OrderStatus, + Delivery, + StockLevel, + } + + private readonly Stats _stats; + private readonly DbConnection _connection; + private readonly int _numWarehouses; + private readonly int _numDistrictsPerWarehouse; + private readonly int _numCustomersPerDistrict; + private readonly int _numItems; + private readonly bool _isClientLib; + + private DbTransaction? _currentTransaction; + + internal TpccRunner( + Stats stats, + DbConnection connection, + int numWarehouses, + int numDistrictsPerWarehouse = 10, + int numCustomersPerDistrict = 3000, + int numItems = 100_000) + { + _stats = stats; + _connection = connection; + _numWarehouses = numWarehouses; + _numDistrictsPerWarehouse = numDistrictsPerWarehouse; + _numCustomersPerDistrict = numCustomersPerDistrict; + _numItems = numItems; + _isClientLib = connection is Data.SpannerConnection; + } + + internal async Task RunAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + await RunTransactionAsync(cancellationToken); + } + } + + internal async Task RunTransactionAsync(CancellationToken cancellationToken) + { + var watch = Stopwatch.StartNew(); + var transaction = Random.Shared.Next(23); + var transactionType = TransactionType.Unknown; + var attempts = 0; + while (true) + { + attempts++; + try + { + if (transaction < 10) + { + transactionType = TransactionType.NewOrder; + await NewOrderAsync(cancellationToken); + } + else if (transaction < 20) + { + transactionType = TransactionType.Payment; + await PaymentAsync(cancellationToken); + } + else if (transaction < 21) + { + transactionType = TransactionType.OrderStatus; + await OrderStatusAsync(cancellationToken); + } + else if (transaction < 22) + { + transactionType = TransactionType.Delivery; + await DeliveryAsync(cancellationToken); + } + else if (transaction < 23) + { + transactionType = TransactionType.StockLevel; + await StockLevelAsync(cancellationToken); + } + else + { + throw new ArgumentException($"Invalid transaction type {transaction}"); + } + + _stats.RegisterTransaction(transactionType, watch.Elapsed); + break; + } + catch (Exception exception) + { + await SilentRollbackTransactionAsync(cancellationToken); + if (attempts < 10) + { + if (exception is SpannerException { Code: Code.Aborted }) + { + continue; + } + + if (exception is Data.SpannerException { ErrorCode: ErrorCode.Aborted }) + { + continue; + } + } + else + { + await Console.Error.WriteLineAsync($"Giving up after {attempts} attempts"); + } + + _stats.RegisterFailedTransaction(transactionType, watch.Elapsed, exception); + break; + } + finally + { + if (_currentTransaction != null) + { + await Console.Error.WriteLineAsync("Transaction still open!"); + await _currentTransaction.DisposeAsync(); + _currentTransaction = null; + } + } + } + } + + private async Task NewOrderAsync(CancellationToken cancellationToken) + { + var warehouseId = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numWarehouses)); + var districtId = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numDistrictsPerWarehouse)); + var customerId = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numCustomersPerDistrict)); + + var orderLineCount = Random.Shared.Next(5, 16); + var itemIds = new long[orderLineCount]; + var supplyWarehouses = new long[orderLineCount]; + var quantities = new int[orderLineCount]; + var rollback = Random.Shared.Next(100); + var allLocal = 1; + + for (var line = 0; line < orderLineCount; line++) + { + if (rollback == 1 && line == orderLineCount - 1) + { + itemIds[line] = DataLoader.ReverseBitsUnsigned(long.MaxValue); + } + else + { + // TODO: Make sure that the chosen item IDs are unique. + itemIds[line] = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numItems)); + } + + if (Random.Shared.Next(100) == 50) + { + supplyWarehouses[line] = GetOtherWarehouseId(warehouseId); + allLocal = 0; + } + else + { + supplyWarehouses[line] = warehouseId; + } + + quantities[line] = Random.Shared.Next(1, 10); + } + + await BeginTransactionAsync("new_order", cancellationToken); + + // TODO: These queries can run in parallel. + var row = await ExecuteRowAsync( + "SELECT c_discount, c_last, c_credit, w_tax " + + "FROM customer c, warehouse w " + + "WHERE w.w_id = $1 AND c.w_id = w.w_id AND c.d_id = $2 AND c.c_id = $3 " + + "FOR UPDATE", cancellationToken, + warehouseId, districtId, customerId); + var discount = ToDecimal(row[0]); + var last = (string)row[1]; + var credit = (string)row[2]; + var warehouseTax = ToDecimal(row[3]); + + row = await ExecuteRowAsync( + "SELECT d_next_o_id, d_tax " + + "FROM district " + + "WHERE w_id = $1 AND d_id = $2 FOR UPDATE", cancellationToken, + warehouseId, districtId); + var districtNextOrderId = row[0] is DBNull ? 0L : (long)row[0]; + var districtTax = ToDecimal(row[1]); + + object batch = _isClientLib ? (_currentTransaction as Data.SpannerTransaction)!.CreateBatchDmlCommand() : _connection.CreateBatch(); + CreateBatchCommand( + batch, + "UPDATE district SET d_next_o_id = $1 WHERE d_id = $2 AND w_id= $3", + districtNextOrderId + 1L, districtId, warehouseId); + CreateBatchCommand( + batch, + "INSERT INTO orders (o_id, d_id, w_id, c_id, o_entry_d, o_ol_cnt, o_all_local) " + + "VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6)", + districtNextOrderId, districtId, warehouseId, customerId, orderLineCount, allLocal); + CreateBatchCommand( + batch, + "INSERT INTO new_orders (o_id, c_id, d_id, w_id) VALUES ($1,$2,$3,$4)", + districtNextOrderId, customerId, districtId, warehouseId); + + for (var line = 0; line < orderLineCount; line++) + { + var orderLineSupplyWarehouseId = supplyWarehouses[line]; + var orderLineItemId = itemIds[line]; + var orderLineQuantity = quantities[line]; + try + { + row = await ExecuteRowAsync( + "SELECT i_price, i_name, i_data FROM item WHERE i_id = $1", + cancellationToken, + orderLineItemId); + } + catch (RowNotFoundException) + { + // TODO: Record deliberate rollback + await RollbackTransactionAsync(cancellationToken); + return; + } + + var itemPrice = ToDecimal(row[0]); + var itemName = (string)row[1]; + var itemData = (string)row[2]; + + row = await ExecuteRowAsync( + "SELECT s_quantity, s_data, s_dist_01, s_dist_02, s_dist_03, s_dist_04, s_dist_05, s_dist_06, s_dist_07, s_dist_08, s_dist_09, s_dist_10 " + + "FROM stock " + + "WHERE s_i_id = $1 AND w_id= $2 FOR UPDATE", + cancellationToken, + orderLineItemId, orderLineSupplyWarehouseId); + var stockQuantity = (long)row[0]; + var stockData = (string)row[1]; + var stockDistrict = new string[10]; + for (int i = 2; i < stockDistrict.Length + 2; i++) + { + stockDistrict[i - 2] = (string)row[i]; + } + + var orderLineDistrictInfo = + stockDistrict[(int)(DataLoader.ReverseBitsUnsigned((ulong)districtId) % stockDistrict.Length)]; + if (stockQuantity > orderLineQuantity) + { + stockQuantity = stockQuantity - orderLineQuantity; + } + else + { + stockQuantity = stockQuantity - orderLineQuantity + 91; + } + + CreateBatchCommand(batch, "UPDATE stock SET s_quantity=$1 WHERE s_i_id=$2 AND w_id=$3", + stockQuantity, orderLineItemId, orderLineSupplyWarehouseId); + + var totalTax = 1m + warehouseTax + districtTax; + var discountFactor = 1m - discount; + var orderLineAmount = orderLineQuantity * itemPrice * totalTax * discountFactor; + CreateBatchCommand(batch, + "INSERT INTO order_line (o_id, c_id, d_id, w_id, ol_number, ol_i_id, ol_supply_w_id, ol_quantity, ol_amount, ol_dist_info) " + + "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + districtNextOrderId, + customerId, + districtId, + warehouseId, + line, + orderLineItemId, + orderLineSupplyWarehouseId, + orderLineQuantity, + orderLineAmount, + orderLineDistrictInfo); + } + + if (batch is Data.SpannerBatchCommand spannerBatchCommand) + { + await spannerBatchCommand.ExecuteNonQueryAsync(cancellationToken); + } + else if (batch is DbBatch dbBatch) + { + await dbBatch.ExecuteNonQueryAsync(cancellationToken); + } + else + { + throw new NotSupportedException("Batch type not supported"); + } + await CommitTransactionAsync(cancellationToken); + } + + private async Task PaymentAsync(CancellationToken cancellationToken) + { + var warehouseId = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numWarehouses)); + var districtId = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numDistrictsPerWarehouse)); + var customerId = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numCustomersPerDistrict)); + var amount = Random.Shared.Next(1, 500000) / 100m; + + long customerWarehouseId; + long customerDistrictId; + var lastName = LastNameGenerator.GenerateLastName(long.MaxValue); + bool byName; + object[] row; + if (Random.Shared.Next(100) < 60) + { + byName = true; + } + else + { + byName = false; + } + if (Random.Shared.Next(100) < 85) + { + customerWarehouseId = warehouseId; + customerDistrictId = districtId; + } + else + { + customerWarehouseId = GetOtherWarehouseId(warehouseId); + customerDistrictId = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numDistrictsPerWarehouse)); + } + await BeginTransactionAsync("payment", cancellationToken); + await ExecuteNonQueryAsync("UPDATE warehouse SET w_ytd = w_ytd + $1 WHERE w_id = $2", + cancellationToken, amount, warehouseId); + + row = await ExecuteRowAsync( + "SELECT w_street_1, w_street_2, w_city, w_state, w_zip, w_name " + + "FROM warehouse " + + "WHERE w_id = $1", + cancellationToken, warehouseId); + var warehouseStreet1 = (string) row[0]; + var warehouseStreet2 = (string) row[1]; + var warehouseCity = (string) row[2]; + var warehouseState = (string) row[3]; + var warehouseZip = (string) row[4]; + var warehouseName = (string) row[5]; + + await ExecuteNonQueryAsync( + "UPDATE district SET d_ytd = d_ytd + $1 WHERE w_id = $2 AND d_id= $3", + cancellationToken, amount, warehouseId, districtId); + + row = await ExecuteRowAsync( + "SELECT d_street_1, d_street_2, d_city, d_state, d_zip, d_name " + + "FROM district " + + "WHERE w_id = $1 AND d_id = $2", + cancellationToken, warehouseId, districtId); + var districtStreet1 = (string) row[0]; + var districtStreet2 = (string) row[1]; + var districtCity = (string) row[2]; + var districtState = (string) row[3]; + var districtZip = (string) row[4]; + var districtName = (string) row[5]; + + if (byName) + { + row = await ExecuteRowAsync( + "SELECT count(c_id) namecnt " + + "FROM customer " + + "WHERE w_id = $1 AND d_id= $2 AND c_last=$3", + cancellationToken, customerWarehouseId, customerDistrictId, lastName); + var nameCount = (int) (long) row[0]; + if (nameCount % 2 == 0) + { + nameCount++; + } + var resultSet = await ExecuteQueryAsync( + "SELECT c_id " + + "FROM customer " + + "WHERE w_id=$1 AND d_id=$2 AND c_last=$3 " + + "ORDER BY c_first", + cancellationToken, customerWarehouseId, customerDistrictId, lastName); + for (var counter = 0; counter < Math.Min(nameCount, resultSet.Count); counter++) + { + customerId = (long) resultSet[counter][0]; + } + } + row = await ExecuteRowAsync( + "SELECT c_first, c_middle, c_last, c_street_1, c_street_2, c_city, c_state, c_zip, c_phone, c_credit, c_credit_lim, c_discount, c_balance, c_ytd_payment, c_since " + + "FROM customer " + + "WHERE w_id=$1 AND d_id=$2 AND c_id=$3 FOR UPDATE", + cancellationToken, customerWarehouseId, customerDistrictId, customerId); + var firstName = (string) row[0]; + var middleName = (string) row[1]; + lastName = (string) row[2]; + var street1 = (string) row[3]; + var street2 = (string) row[4]; + var city = (string) row[5]; + var state = (string) row[6]; + var zip = (string) row[7]; + var phone = (string) row[8]; + var credit = (string) row[9]; + var creditLimit = (long) row[10]; + var discount = ToDecimal(row[11]); + var balance = ToDecimal(row[12]); + var ytdPayment = ToDecimal(row[13]); + var since = (DateTime) row[14]; + + // TODO: Use batching from here + balance = balance - amount; + ytdPayment = ytdPayment + amount; + if ("BC".Equals(credit)) + { + row = await ExecuteRowAsync( + "SELECT c_data FROM customer WHERE w_id=$1 AND d_id=$2 AND c_id=$3", + cancellationToken, customerWarehouseId, customerDistrictId, customerId); + var customerData = (string)row[0]; + var newCustomerData = + $"| {customerId} {customerDistrictId} {customerWarehouseId} {districtId} {warehouseId} {amount} {DateTime.Now} {customerData}"; + if (newCustomerData.Length > 500) + { + newCustomerData = newCustomerData.Substring(0, 500); + } + await ExecuteNonQueryAsync( + "UPDATE customer " + + "SET c_balance=$1, c_ytd_payment=$2, c_data=$3 " + + "WHERE w_id = $4 AND d_id=$5 AND c_id=$6", + cancellationToken, + balance, + ytdPayment, + newCustomerData, + customerWarehouseId, + customerDistrictId, + customerId + ); + } + else + { + await ExecuteNonQueryAsync( + "UPDATE customer " + + "SET c_balance=$1, c_ytd_payment=$2 " + + "WHERE w_id = $3 AND d_id=$4 AND c_id=$5", + cancellationToken, balance, ytdPayment, customerWarehouseId, customerDistrictId, customerId); + } + + var data = $"{warehouseName} {districtName}"; + if (data.Length > 24) + { + data = data.Substring(0, 24); + } + await ExecuteNonQueryAsync( + "INSERT INTO history (d_id, w_id, c_id, h_d_id, h_w_id, h_date, h_amount, h_data) " + + "VALUES ($1,$2,$3,$4,$5,CURRENT_TIMESTAMP,$6,$7)", + cancellationToken, + customerDistrictId, + customerWarehouseId, + customerId, + districtId, + warehouseId, + amount, + data); + + await CommitTransactionAsync(cancellationToken); + } + + private async Task OrderStatusAsync(CancellationToken cancellationToken) + { + var warehouseId = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numWarehouses)); + var districtId = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numDistrictsPerWarehouse)); + var customerId = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numCustomersPerDistrict)); + + var lastName = LastNameGenerator.GenerateLastName(long.MaxValue); + object[] row; + var byName = Random.Shared.Next(100) < 60; + + decimal balance; + string first, middle, last; + + await BeginTransactionAsync("order_status", cancellationToken); + if (byName) + { + row = await ExecuteRowAsync( + "SELECT count(c_id) namecnt " + + "FROM customer " + + "WHERE w_id=$1 AND d_id=$2 AND c_last=$3", + cancellationToken, warehouseId, districtId, lastName); + int nameCount = (int) (long) row[0]; + if (nameCount % 2 == 0) + { + nameCount++; + } + var resultSet = await ExecuteQueryAsync( + "SELECT c_balance, c_first, c_middle, c_id " + + "FROM customer WHERE w_id = $1 AND d_id=$2 AND c_last=$3 " + + "ORDER BY c_first", + cancellationToken, warehouseId, districtId, lastName); + for (int counter = 0; counter < Math.Min(nameCount, resultSet.Count); counter++) + { + balance = ToDecimal(resultSet[counter][0]); + first = (string) resultSet[counter][1]; + middle = (string) resultSet[counter][2]; + customerId = (long) resultSet[counter][3]; + } + } + else + { + row = await ExecuteRowAsync( + "SELECT c_balance, c_first, c_middle, c_last " + + "FROM customer " + + "WHERE w_id = $1 AND d_id=$2 AND c_id=$3", + cancellationToken, warehouseId, districtId, customerId); + balance = ToDecimal(row[0]); + first = (string) row[1]; + middle = (string) row[2]; + last = (string) row[3]; + } + + var maybeRow = await ExecuteRowAsync(false, + "SELECT o_id, o_carrier_id, o_entry_d " + + "FROM orders " + + "WHERE w_id = $1 AND d_id = $2 AND c_id = $3 " + + "ORDER BY o_id DESC", + cancellationToken, warehouseId, districtId, customerId); + var orderId = maybeRow == null ? 0L : (long) maybeRow[0]; + + long item_id, supply_warehouse_id, quantity; + decimal amount; + DateTime? delivery_date; + var results = await ExecuteQueryAsync( + "SELECT ol_i_id, ol_supply_w_id, ol_quantity, ol_amount, ol_delivery_d " + + "FROM order_line " + + "WHERE w_id = $1 AND d_id = $2 AND o_id = $3", + cancellationToken, warehouseId, districtId, orderId); + for (var counter = 0; counter < results.Count; counter++) + { + item_id = (long) results[counter][0]; // item_id + supply_warehouse_id = (long) results[counter][1]; // supply_warehouse_id + quantity = (long) results[counter][2]; // quantity + amount = ToDecimal(results[counter][3]); // amount + delivery_date = results[counter][4] is DBNull ? null : (DateTime) results[counter][4]; // delivery_date + } + await CommitTransactionAsync(cancellationToken); + } + + private async Task DeliveryAsync(CancellationToken cancellationToken) + { + var warehouseId = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numWarehouses)); + var carrierId = Random.Shared.Next(10); + + await BeginTransactionAsync("delivery", cancellationToken); + + for (var district = 0L; district < _numDistrictsPerWarehouse; district++) + { + var districtId = DataLoader.ReverseBitsUnsigned((ulong)district); + var row = await ExecuteRowAsync(false, + "SELECT o_id, c_id " + + "FROM new_orders " + + "WHERE d_id = $1 AND w_id = $2 " + + "ORDER BY o_id ASC " + + "LIMIT 1 FOR UPDATE", + cancellationToken, districtId, warehouseId); + if (row != null) + { + var newOrderId = (long)row[0]; + var customerId = (long)row[1]; + await ExecuteNonQueryAsync( + "DELETE " + + "FROM new_orders " + + "WHERE o_id = $1 AND c_id = $2 AND d_id = $3 AND w_id = $4", + cancellationToken, newOrderId, customerId, districtId, warehouseId); + row = await ExecuteRowAsync( + "SELECT c_id FROM orders WHERE o_id = $1 AND d_id = $2 AND w_id = $3", + cancellationToken, newOrderId, districtId, warehouseId); + await ExecuteNonQueryAsync( + "UPDATE orders " + + "SET o_carrier_id = $1 " + + "WHERE o_id = $2 AND c_id = $3 AND d_id = $4 AND w_id = $5", + cancellationToken, carrierId, newOrderId, customerId, districtId, warehouseId); + await ExecuteNonQueryAsync( + "UPDATE order_line " + + "SET ol_delivery_d = CURRENT_TIMESTAMP " + + "WHERE o_id = $1 AND c_id = $2 AND d_id = $3 AND w_id = $4", + cancellationToken, newOrderId, customerId, districtId, warehouseId); + row = await ExecuteRowAsync( + "SELECT SUM(ol_amount) sm " + + "FROM order_line " + + "WHERE o_id = $1 AND c_id = $2 AND d_id = $3 AND w_id = $4", + cancellationToken, newOrderId, customerId, districtId, warehouseId); + var sumOrderLineAmount = ToDecimal(row[0]); + await ExecuteNonQueryAsync( + "UPDATE customer " + + "SET c_balance = c_balance + $1, c_delivery_cnt = c_delivery_cnt + 1 " + + "WHERE c_id = $2 AND d_id = $3 AND w_id = $4", + cancellationToken, sumOrderLineAmount, customerId, districtId, warehouseId); + } + } + await CommitTransactionAsync(cancellationToken); + } + + private async Task StockLevelAsync(CancellationToken cancellationToken) + { + var warehouseId = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numWarehouses)); + var districtId = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numDistrictsPerWarehouse)); + var level = Random.Shared.Next(10, 21); + + await BeginTransactionAsync("stock_level", cancellationToken); + String stockLevelQueries = "case1"; + Object[] row; + + row = await ExecuteRowAsync( + "SELECT d_next_o_id FROM district WHERE d_id = $1 AND w_id= $2", + cancellationToken, districtId, warehouseId); + var nextOrderId = row[0] is DBNull ? 0L : (long) row[0]; + var resultSet = + await ExecuteQueryAsync( + "SELECT COUNT(DISTINCT (s_i_id)) " + + "FROM order_line ol, stock s " + + "WHERE ol.w_id = $1 " + + "AND ol.d_id = $2 " + + "AND ol.o_id < $3 " + + "AND ol.o_id >= $4 " + + "AND s.w_id= $5 " + + "AND s_i_id=ol_i_id " + + "AND s_quantity < $6", + cancellationToken, + warehouseId, districtId, nextOrderId, nextOrderId - 20, warehouseId, level); + for (var counter = 0; counter < resultSet.Count; counter++) { + var orderLineItemId = (long) resultSet[counter][0]; + row = await ExecuteRowAsync( + "SELECT count(1) FROM stock " + + "WHERE w_id = $1 AND s_i_id = $2 " + + "AND s_quantity < $3", + cancellationToken, warehouseId, orderLineItemId, level); + var stockCount = (long) row[0]; + } + + await CommitTransactionAsync(cancellationToken); + } + + private decimal ToDecimal(object value) + { + return _isClientLib ? ((PgNumeric) value).ToDecimal(LossOfPrecisionHandling.Truncate) : (decimal) value; + } + + private async Task BeginTransactionAsync(string tag, CancellationToken cancellationToken = default) + { + if (_connection is Data.SpannerConnection spannerConnection) + { + _currentTransaction = await spannerConnection.BeginTransactionAsync( + SpannerTransactionCreationOptions.ReadWrite.WithIsolationLevel(IsolationLevel.RepeatableRead), + new SpannerTransactionOptions + { + Tag = tag, + }, + cancellationToken); + } + else if (_connection is SpannerConnection connection) + { + _currentTransaction = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, cancellationToken); + await ExecuteNonQueryAsync($"set local transaction_tag = '{tag}'", cancellationToken); + } + } + + private async Task CommitTransactionAsync(CancellationToken cancellationToken = default) + { + if (_currentTransaction != null) + { + await _currentTransaction.CommitAsync(cancellationToken); + await _currentTransaction.DisposeAsync(); + _currentTransaction = null; + } + } + + private async Task SilentRollbackTransactionAsync(CancellationToken cancellationToken = default) + { + try + { + if (_currentTransaction != null) + { + await RollbackTransactionAsync(cancellationToken); + } + else + { + await ExecuteNonQueryAsync("rollback", cancellationToken); + } + } + catch (Exception) + { + if (_currentTransaction != null) + { + await _currentTransaction.DisposeAsync(); + _currentTransaction = null; + } + } + } + + private async Task RollbackTransactionAsync(CancellationToken cancellationToken = default) + { + if (_currentTransaction != null) + { + await _currentTransaction.RollbackAsync(cancellationToken); + await _currentTransaction.DisposeAsync(); + _currentTransaction = null; + } + } + + private void CreateBatchCommand(object batch, string commandText, params object[] parameters) + { + if (batch is Data.SpannerBatchCommand command) + { + CreateBatchCommand(command, commandText, parameters); + } + else if (batch is DbBatch dbBatch) + { + CreateBatchCommand(dbBatch, commandText, parameters); + } + else + { + throw new ArgumentException("unknown batch type"); + } + } + + private void CreateBatchCommand(Data.SpannerBatchCommand batch, string commandText, params object[] parameters) + { + var paramCollection = new Data.SpannerParameterCollection(); + for (var i=0; i < parameters.Length; i++) + { + var value = parameters[i]; + if (value is decimal d) + { + value = PgNumeric.FromDecimal(d); + } + paramCollection.Add(new Data.SpannerParameter {ParameterName = $"p{i+1}", Value = value}); + } + batch.Add(commandText, paramCollection); + } + + private void CreateBatchCommand(DbBatch batch, string commandText, params object[] parameters) + { + var batchCommand = batch.CreateBatchCommand(); + batchCommand.CommandText = commandText; + for (var i = 0; i < parameters.Length; i++) + { + CreateParameter(batchCommand, $"p{i+1}", parameters[i]); + } + batch.BatchCommands.Add(batchCommand); + } + + private void CreateParameter(DbBatchCommand cmd, string parameterName, object parameterValue) + { + var parameter = cmd.CreateParameter(); + parameter.ParameterName = parameterName; + parameter.Value = parameterValue; + cmd.Parameters.Add(parameter); + } + + private async Task ExecuteNonQueryAsync(string commandText, + CancellationToken cancellationToken, params object[] parameters) + { + using var command = CreateCommand(commandText, parameters); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private Task ExecuteRowAsync(string commandText, + CancellationToken cancellationToken, params object[] parameters) + { + return ExecuteRowAsync(true, commandText, cancellationToken, parameters)!; + } + + private async Task ExecuteRowAsync(bool mustFindRow, string commandText, + CancellationToken cancellationToken, params object[] parameters) + { + using var command = CreateCommand(commandText, parameters); + using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + if (mustFindRow) + { + throw new RowNotFoundException("Row not found"); + } + return null; + } + var result = new object[reader.FieldCount]; + for (var i = 0; i < reader.FieldCount; i++) + { + result[i] = reader.GetValue(i); + } + return result; + } + + private async Task> ExecuteQueryAsync(string commandText, + CancellationToken cancellationToken, params object[] parameters) + { + using var command = CreateCommand(commandText, parameters); + using var reader = await command.ExecuteReaderAsync(cancellationToken); + var result = new List(); + while (await reader.ReadAsync(cancellationToken)) + { + var row = new object[reader.FieldCount]; + for (var i = 0; i < reader.FieldCount; i++) + { + row[i] = reader.GetValue(i); + } + result.Add(row); + } + return result; + } + + private DbCommand CreateCommand(string commandText, params object[] parameters) + { + var command = _connection.CreateCommand(); + command.CommandText = commandText; + command.Transaction = _currentTransaction; + for (var i = 0; i < parameters.Length; i++) + { + CreateParameter(command, $"p{i+1}", parameters[i]); + } + return command; + } + + private void CreateParameter(DbCommand cmd, string parameterName, object parameterValue) + { + var parameter = cmd.CreateParameter(); + parameter.ParameterName = parameterName; + if (_isClientLib) + { + var value = parameterValue; + if (value is decimal d) + { + value = PgNumeric.FromDecimal(d); + } + parameter.Value = value; + } + else + { + parameter.Value = parameterValue; + } + cmd.Parameters.Add(parameter); + } + + private long GetOtherWarehouseId(long currentId) { + if (_numWarehouses == 1) { + return currentId; + } + while (true) { + var otherId = DataLoader.ReverseBitsUnsigned((ulong)Random.Shared.Next(_numWarehouses)); + if (otherId != currentId) { + return otherId; + } + } + } + +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/CustomerLoader.cs b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/CustomerLoader.cs new file mode 100644 index 00000000..d5f82835 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/CustomerLoader.cs @@ -0,0 +1,112 @@ +using System.Globalization; +using Google.Cloud.Spanner.V1; +using Google.Protobuf.WellKnownTypes; + +namespace Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc.loader; + +internal class CustomerLoader +{ + private readonly SpannerConnection _connection; + + private readonly int _warehouseCount; + + private readonly int _districtsPerWarehouse; + + private readonly int _customersPerDistrict; + + internal CustomerLoader(SpannerConnection connection, int warehouseCount, int districtsPerWarehouse, int customersPerDistrict) + { + _connection = connection; + _warehouseCount = warehouseCount; + _districtsPerWarehouse = districtsPerWarehouse; + _customersPerDistrict = customersPerDistrict; + } + + internal async Task LoadAsync(CancellationToken cancellationToken = default) + { + var count = await CountAsync(cancellationToken); + if (count >= _warehouseCount * _districtsPerWarehouse * _customersPerDistrict) + { + return; + } + + for (var warehouse = 0; warehouse < _warehouseCount; warehouse++) + { + for (var district = 0; district < _districtsPerWarehouse; district++) + { + var group = new BatchWriteRequest.Types.MutationGroup + { + Mutations = { Capacity = 1 } + }; + group.Mutations.Add(CreateMutation(warehouse, district, _customersPerDistrict)); + await _connection.WriteMutationsAsync(group, cancellationToken); + } + } + } + + private async Task CountAsync(CancellationToken cancellationToken = default) + { + await using var command = _connection.CreateCommand(); + command.CommandText = "SELECT COUNT(1) FROM customer"; + var result = await command.ExecuteScalarAsync(cancellationToken); + return result == null ? 0L : (long) result; + } + + private Mutation CreateMutation(int warehouse, int district, int rows) + { + var mutation = new Mutation + { + InsertOrUpdate = new Mutation.Types.Write + { + Table = "customer", + Columns = { "c_id", "d_id", "w_id", "c_first", "c_middle", "c_last", "c_street_1", "c_street_2", + "c_city", "c_state", "c_zip", "c_phone", "c_since", "c_credit", "c_credit_lim", "c_discount", + "c_balance", "c_ytd_payment", "c_payment_cnt", "c_delivery_cnt", "c_data", + }, + Values = + { + Capacity = _customersPerDistrict, + } + } + }; + for (var i = 0; i < rows; i++) + { + mutation.InsertOrUpdate.Values.Add(CreateRandomCustomer(warehouse, district, i)); + } + return mutation; + } + + private ListValue CreateRandomCustomer(int warehouse, int district, int index) + { + var row = new ListValue + { + Values = + { + Capacity = 22 + } + }; + row.Values.Add(Value.ForString($"{DataLoader.ReverseBitsUnsigned((ulong) index)}")); + row.Values.Add(Value.ForString($"{DataLoader.ReverseBitsUnsigned((ulong) district)}")); + row.Values.Add(Value.ForString($"{DataLoader.ReverseBitsUnsigned((ulong) warehouse)}")); + row.Values.Add(Value.ForString(DataLoader.RandomString(16))); + row.Values.Add(Value.ForString(DataLoader.RandomString(2))); + row.Values.Add(Value.ForString(LastNameGenerator.GenerateLastName(index))); + row.Values.Add(Value.ForString(DataLoader.RandomString(20))); + row.Values.Add(Value.ForString(DataLoader.RandomString(20))); + row.Values.Add(Value.ForString(DataLoader.RandomString(20))); + row.Values.Add(Value.ForString(DataLoader.RandomString(2))); + row.Values.Add(Value.ForString(DataLoader.RandomString(9))); + row.Values.Add(Value.ForString(DataLoader.RandomString(16))); + row.Values.Add(Value.ForString(DataLoader.RandomTimestamp())); + row.Values.Add(Value.ForString(Random.Shared.Next(2) == 0 ? "GC" : "BC")); + row.Values.Add(Value.ForString(Random.Shared.Next(100, 5000).ToString(CultureInfo.InvariantCulture))); + row.Values.Add(Value.ForString(DataLoader.RandomDecimal(1, 40))); + row.Values.Add(Value.ForString("0.0")); + row.Values.Add(Value.ForString("0.0")); + row.Values.Add(Value.ForString("0")); + row.Values.Add(Value.ForString("0")); + row.Values.Add(Value.ForString(DataLoader.RandomString(500))); + + return row; + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/DataLoader.cs b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/DataLoader.cs new file mode 100644 index 00000000..a965b3d6 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/DataLoader.cs @@ -0,0 +1,83 @@ +using System.Globalization; +using System.Xml; + +namespace Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc.loader; + +public class DataLoader +{ + private readonly SpannerConnection _connection; + private readonly int _numWarehouses; + private readonly int _numDistrictsPerWarehouse; + private readonly int _numCustomersPerDistrict; + private readonly int _numItems; + + public DataLoader( + SpannerConnection connection, + int numWarehouses, + int numDistrictsPerWarehouse = 10, + int numCustomersPerDistrict = 3000, + int numItems = 100_000) + { + _connection = connection; + _numWarehouses = numWarehouses; + _numDistrictsPerWarehouse = numDistrictsPerWarehouse; + _numCustomersPerDistrict = numCustomersPerDistrict; + _numItems = numItems; + } + + public async Task LoadAsync(CancellationToken cancellationToken) + { + Console.WriteLine("Loading warehouses..."); + var warehouseLoader = new WarehouseLoader(_connection, _numWarehouses); + await warehouseLoader.LoadAsync(cancellationToken); + Console.WriteLine("Loading items..."); + var itemLoader = new ItemLoader(_connection, _numItems); + await itemLoader.LoadAsync(cancellationToken); + Console.WriteLine("Loading districts..."); + var districtLoader = new DistrictLoader(_connection, _numWarehouses, _numDistrictsPerWarehouse); + await districtLoader.LoadAsync(cancellationToken); + Console.WriteLine("Loading customers..."); + var customerLoader = new CustomerLoader(_connection, _numWarehouses, _numDistrictsPerWarehouse, _numCustomersPerDistrict); + await customerLoader.LoadAsync(cancellationToken); + Console.WriteLine("Loading stock..."); + var stockLoader = new StockLoader(_connection, _numWarehouses, _numItems); + await stockLoader.LoadAsync(cancellationToken); + } + + public static long ReverseBitsUnsigned(ulong n) + { + // Step 1: Swap adjacent bits + n = ((n >> 1) & 0x5555555555555555UL) | ((n & 0x5555555555555555UL) << 1); + // Step 2: Swap adjacent pairs of bits + n = ((n >> 2) & 0x3333333333333333UL) | ((n & 0x3333333333333333UL) << 2); + // Step 3: Swap adjacent nibbles (4 bits) + n = ((n >> 4) & 0x0F0F0F0F0F0F0F0FUL) | ((n & 0x0F0F0F0F0F0F0F0FUL) << 4); + // Step 4: Swap adjacent bytes + n = ((n >> 8) & 0x00FF00FF00FF00FFUL) | ((n & 0x00FF00FF00FF00FFUL) << 8); + // Step 5: Swap adjacent 2-byte words + n = ((n >> 16) & 0x0000FFFF0000FFFFUL) | ((n & 0x0000FFFF0000FFFFUL) << 16); + // Step 6: Swap the high and low 4-byte words (32 bits) + n = (n >> 32) | (n << 32); + return (long) n; + } + + internal static string RandomString(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + return new string(Enumerable.Repeat(chars, length) + .Select(s => s[Random.Shared.Next(s.Length)]).ToArray()); + } + + internal static string RandomDecimal(int min, int max) + { + var d = (decimal) Random.Shared.Next(min, max) / 100; + return d.ToString("F", CultureInfo.InvariantCulture); + } + + internal static string RandomTimestamp() + { + var ts = DateTime.UtcNow.AddTicks(-Random.Shared.NextInt64(10 * 365 * TimeSpan.TicksPerDay)); + return XmlConvert.ToString(Convert.ToDateTime(ts, CultureInfo.InvariantCulture), + XmlDateTimeSerializationMode.Utc); + } +} diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/DistrictLoader.cs b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/DistrictLoader.cs new file mode 100644 index 00000000..5e339853 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/DistrictLoader.cs @@ -0,0 +1,89 @@ +using Google.Cloud.Spanner.V1; +using Google.Protobuf.WellKnownTypes; + +namespace Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc.loader; + +internal class DistrictLoader +{ + private readonly SpannerConnection _connection; + + private readonly int _warehouseCount; + + private readonly int _districtsPerWarehouse; + + internal DistrictLoader(SpannerConnection connection, int warehouseCount, int districtsPerWarehouse) + { + _connection = connection; + _warehouseCount = warehouseCount; + _districtsPerWarehouse = districtsPerWarehouse; + } + + internal async Task LoadAsync(CancellationToken cancellationToken = default) + { + var count = await CountAsync(cancellationToken); + if (count >= _warehouseCount * _districtsPerWarehouse) + { + return; + } + for (var warehouse = 0; warehouse < _warehouseCount; warehouse++) + { + var group = new BatchWriteRequest.Types.MutationGroup + { + Mutations = { Capacity = 1 } + }; + group.Mutations.Add(CreateMutation(warehouse, _districtsPerWarehouse)); + await _connection.WriteMutationsAsync(group, cancellationToken); + } + } + + private async Task CountAsync(CancellationToken cancellationToken = default) + { + await using var command = _connection.CreateCommand(); + command.CommandText = "SELECT COUNT(1) FROM district"; + var result = await command.ExecuteScalarAsync(cancellationToken); + return result == null ? 0L : (long) result; + } + + private Mutation CreateMutation(int warehouse, int rows) + { + var mutation = new Mutation + { + InsertOrUpdate = new Mutation.Types.Write + { + Table = "district", + Columns = { "d_id", "w_id", "d_name", "d_street_1", "d_street_2", "d_city", "d_state", "d_zip", "d_tax", "d_ytd" }, + Values = + { + Capacity = _districtsPerWarehouse, + } + } + }; + for (var i = 0; i < rows; i++) + { + mutation.InsertOrUpdate.Values.Add(CreateRandomDistrict(warehouse, i)); + } + return mutation; + } + + private ListValue CreateRandomDistrict(int warehouse, int index) + { + var row = new ListValue + { + Values = + { + Capacity = 10 + } + }; + row.Values.Add(Value.ForString($"{DataLoader.ReverseBitsUnsigned((ulong) index)}")); + row.Values.Add(Value.ForString($"{DataLoader.ReverseBitsUnsigned((ulong) warehouse)}")); + row.Values.Add(Value.ForString($"W#{warehouse}D#{index}")); + row.Values.Add(Value.ForString(DataLoader.RandomString(20))); + row.Values.Add(Value.ForString(DataLoader.RandomString(20))); + row.Values.Add(Value.ForString(DataLoader.RandomString(20))); + row.Values.Add(Value.ForString(DataLoader.RandomString(2))); + row.Values.Add(Value.ForString(DataLoader.RandomString(9))); + row.Values.Add(Value.ForString(DataLoader.RandomDecimal(0, 21))); + row.Values.Add(Value.ForString("0.0")); + return row; + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/ItemLoader.cs b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/ItemLoader.cs new file mode 100644 index 00000000..aed4c3ee --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/ItemLoader.cs @@ -0,0 +1,90 @@ +using Google.Cloud.Spanner.V1; +using Google.Protobuf.WellKnownTypes; + +namespace Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc.loader; + +internal class ItemLoader +{ + private static readonly int RowsPerGroup = 1000; + + private readonly SpannerConnection _connection; + + private readonly int _rowCount; + + internal ItemLoader(SpannerConnection connection, int rowCount) + { + _connection = connection; + _rowCount = rowCount; + } + + internal async Task LoadAsync(CancellationToken cancellationToken = default) + { + var count = await CountAsync(cancellationToken); + if (count >= _rowCount) + { + return; + } + + var batch = 0; + var remaining = _rowCount; + while (remaining > 0) + { + var group = new BatchWriteRequest.Types.MutationGroup + { + Mutations = { Capacity = 1 } + }; + var rows = Math.Min(RowsPerGroup, remaining); + group.Mutations.Add(CreateMutation(batch, rows)); + await _connection.WriteMutationsAsync(group, cancellationToken); + remaining -= rows; + batch++; + } + } + + private async Task CountAsync(CancellationToken cancellationToken = default) + { + await using var command = _connection.CreateCommand(); + command.CommandText = "SELECT COUNT(1) FROM item"; + var result = await command.ExecuteScalarAsync(cancellationToken); + return result == null ? 0L : (long) result; + } + + private Mutation CreateMutation(int batch, int rows) + { + var mutation = new Mutation + { + InsertOrUpdate = new Mutation.Types.Write + { + Table = "item", + Columns = { "i_id", "i_im_id", "i_name", "i_price", "i_data" }, + Values = + { + Capacity = _rowCount, + } + } + }; + for (var i = 0; i < rows; i++) + { + mutation.InsertOrUpdate.Values.Add(CreateRandomItem(batch, i)); + } + return mutation; + } + + private ListValue CreateRandomItem(int batch, int index) + { + var row = new ListValue + { + Values = + { + Capacity = 5 + } + }; + var id = (long)batch * RowsPerGroup + index; + row.Values.Add(Value.ForString($"{DataLoader.ReverseBitsUnsigned((ulong) id)}")); + row.Values.Add(Value.ForString($"{Random.Shared.Next(1, 2000001)}")); + row.Values.Add(Value.ForString(DataLoader.RandomString(24))); + row.Values.Add(Value.ForString(DataLoader.RandomDecimal(100, 10001))); + row.Values.Add(Value.ForString(DataLoader.RandomString(50))); + return row; + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/StockLoader.cs b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/StockLoader.cs new file mode 100644 index 00000000..05cdff2c --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/StockLoader.cs @@ -0,0 +1,119 @@ +using Google.Cloud.Spanner.V1; +using Google.Protobuf.WellKnownTypes; + +namespace Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc.loader; + +internal class StockLoader +{ + private static readonly int RowsPerGroup = 1000; + + private readonly SpannerConnection _connection; + + private readonly int _warehouseCount; + + private readonly int _numItems; + + internal StockLoader(SpannerConnection connection, int warehouseCount, int numItems) + { + _connection = connection; + _warehouseCount = warehouseCount; + _numItems = numItems; + } + + internal async Task LoadAsync(CancellationToken cancellationToken = default) + { + var count = await CountAsync(cancellationToken); + if (count >= _warehouseCount * _numItems) + { + return; + } + for (var warehouse = 0; warehouse < _warehouseCount; warehouse++) + { + for (var item=0; item<_numItems; item += RowsPerGroup) + { + var group = new BatchWriteRequest.Types.MutationGroup + { + Mutations = { Capacity = 1 } + }; + group.Mutations.Add(CreateMutation(warehouse, item, RowsPerGroup)); + await _connection.WriteMutationsAsync(group, cancellationToken); + } + } + } + + private async Task CountAsync(CancellationToken cancellationToken = default) + { + await using var command = _connection.CreateCommand(); + command.CommandText = "SELECT COUNT(1) FROM stock"; + var result = await command.ExecuteScalarAsync(cancellationToken); + return result == null ? 0L : (long) result; + } + + private Mutation CreateMutation(int warehouse, int item, int rows) + { + var mutation = new Mutation + { + InsertOrUpdate = new Mutation.Types.Write + { + Table = "stock", + Columns = { "s_i_id", "w_id", "s_quantity", "s_dist_01", "s_dist_02", "s_dist_03", "s_dist_04", "s_dist_05", + "s_dist_06", "s_dist_07", "s_dist_08", "s_dist_09", "s_dist_10", "s_ytd", "s_order_cnt", "s_remote_cnt", "s_data" }, + Values = + { + Capacity = _numItems, + } + } + }; + for (var i = 0; i < rows; i++) + { + mutation.InsertOrUpdate.Values.Add(CreateRandomStock(warehouse, item, i)); + } + return mutation; + } + + private ListValue CreateRandomStock(int warehouse, int item, int index) + { + var row = new ListValue + { + Values = + { + Capacity = 10 + } + }; + // s_i_id int not null, + // w_id int not null, + // s_quantity int, + // s_dist_01 varchar(24), + // s_dist_02 varchar(24), + // s_dist_03 varchar(24), + // s_dist_04 varchar(24), + // s_dist_05 varchar(24), + // s_dist_06 varchar(24), + // s_dist_07 varchar(24), + // s_dist_08 varchar(24), + // s_dist_09 varchar(24), + // s_dist_10 varchar(24), + // s_ytd decimal, + // s_order_cnt int, + // s_remote_cnt int, + // s_data varchar(50), + row.Values.Add(Value.ForString($"{DataLoader.ReverseBitsUnsigned((ulong) (item + index))}")); + row.Values.Add(Value.ForString($"{DataLoader.ReverseBitsUnsigned((ulong) warehouse)}")); + row.Values.Add(Value.ForString(Random.Shared.Next(1, 500).ToString())); + row.Values.Add(Value.ForString(DataLoader.RandomString(24))); + row.Values.Add(Value.ForString(DataLoader.RandomString(24))); + row.Values.Add(Value.ForString(DataLoader.RandomString(24))); + row.Values.Add(Value.ForString(DataLoader.RandomString(24))); + row.Values.Add(Value.ForString(DataLoader.RandomString(24))); + row.Values.Add(Value.ForString(DataLoader.RandomString(24))); + row.Values.Add(Value.ForString(DataLoader.RandomString(24))); + row.Values.Add(Value.ForString(DataLoader.RandomString(24))); + row.Values.Add(Value.ForString(DataLoader.RandomString(24))); + row.Values.Add(Value.ForString(DataLoader.RandomString(24))); + row.Values.Add(Value.ForString("0.0")); + row.Values.Add(Value.ForString("0")); + row.Values.Add(Value.ForString("0")); + row.Values.Add(Value.ForString(DataLoader.RandomString(50))); + return row; + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/WarehouseLoader.cs b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/WarehouseLoader.cs new file mode 100644 index 00000000..a9cb6fd4 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-benchmarks/tpcc/loader/WarehouseLoader.cs @@ -0,0 +1,67 @@ +using Google.Cloud.Spanner.V1; +using Google.Protobuf.WellKnownTypes; + +namespace Google.Cloud.Spanner.DataProvider.Benchmarks.tpcc.loader; + +internal class WarehouseLoader +{ + private readonly SpannerConnection _connection; + + private readonly int _rowCount; + + internal WarehouseLoader(SpannerConnection connection, int rowCount) + { + _connection = connection; + _rowCount = rowCount; + } + + internal Task LoadAsync(CancellationToken cancellationToken = default) + { + return _connection.WriteMutationsAsync(new BatchWriteRequest.Types.MutationGroup + { + Mutations = { CreateMutation() } + }, cancellationToken); + } + + private Mutation CreateMutation() + { + var mutation = new Mutation + { + InsertOrUpdate = new Mutation.Types.Write + { + Table = "warehouse", + Columns = { "w_id", "w_name", "w_street_1", "w_street_2", "w_city", "w_state", "w_zip", "w_tax", "w_ytd" }, + Values = + { + Capacity = _rowCount, + } + } + }; + for (var i = 0; i < _rowCount; i++) + { + mutation.InsertOrUpdate.Values.Add(CreateRandomWarehouse(i)); + } + return mutation; + } + + private ListValue CreateRandomWarehouse(int index) + { + var row = new ListValue + { + Values = + { + Capacity = 9 + } + }; + row.Values.Add(Value.ForString($"{DataLoader.ReverseBitsUnsigned((ulong) index)}")); + row.Values.Add(Value.ForString($"W#{index}")); + row.Values.Add(Value.ForString(DataLoader.RandomString(20))); + row.Values.Add(Value.ForString(DataLoader.RandomString(20))); + row.Values.Add(Value.ForString(DataLoader.RandomString(20))); + row.Values.Add(Value.ForString(DataLoader.RandomString(2))); + row.Values.Add(Value.ForString(DataLoader.RandomString(9))); + row.Values.Add(Value.ForString(DataLoader.RandomDecimal(0, 21))); + row.Values.Add(Value.ForString("0.0")); + return row; + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-samples/Program.cs b/drivers/spanner-ado-net/spanner-ado-net-samples/Program.cs new file mode 100644 index 00000000..c5287edf --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-samples/Program.cs @@ -0,0 +1,37 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Google.Cloud.Spanner.DataProvider; + +using var connection = new SpannerConnection {ConnectionString = "/projects/appdev-soda-spanner-staging/instances/knut-test-ycsb/databases/knut-test-db"}; +connection.Open(); +using var cmd = connection.CreateCommand(); +cmd.CommandText = "select * from all_types where col_varchar is not null limit 10"; + +using var reader = cmd.ExecuteReader(); +for (int i = 0; i < reader.FieldCount; i++) +{ + Console.Write(reader.GetName(i)); + Console.Write("|"); +} +Console.WriteLine(); +while (reader.Read()) +{ + for (int i = 0; i < reader.FieldCount; i++) + { + Console.Write(reader.GetValue(i)); + Console.Write("|"); + } + Console.WriteLine(); +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-samples/README.md b/drivers/spanner-ado-net/spanner-ado-net-samples/README.md new file mode 100644 index 00000000..b07bb9b5 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-samples/README.md @@ -0,0 +1,5 @@ +# Spanner ADO.NET Data Provider Samples + +Samples for ADO.NET Data Provider for Spanner. + +__ALPHA: Not for production use__ diff --git a/drivers/spanner-ado-net/spanner-ado-net-samples/spanner-ado-net-samples.csproj b/drivers/spanner-ado-net/spanner-ado-net-samples/spanner-ado-net-samples.csproj new file mode 100644 index 00000000..3c247b26 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-samples/spanner-ado-net-samples.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + Google.Cloud.Spanner.DataProvider.Samples + enable + enable + Google.Cloud.Spanner.DataProvider.Samples + default + + + + + + + diff --git a/drivers/spanner-ado-net/spanner-ado-net-specification-tests/CommandTests.cs b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/CommandTests.cs new file mode 100644 index 00000000..a2feb8ab --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/CommandTests.cs @@ -0,0 +1,123 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using AdoNet.Specification.Tests; +using Google.Cloud.SpannerLib.MockServer; +using Xunit; +using TypeCode = Google.Cloud.Spanner.V1.TypeCode; + +namespace Google.Cloud.Spanner.DataProvider.SpecificationTests; + +public class CommandTests(DbFactoryFixture fixture) : CommandTestBase(fixture) +{ + [Fact] + public override void Execute_throws_for_unknown_ParameterValue_type() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT @Parameter;", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.String}, "Parameter", new CustomClass().ToString())); + base.Execute_throws_for_unknown_ParameterValue_type(); + } + + [Fact] + public override void ExecuteReader_binds_parameters() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT @Parameter;", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.Int64}, "Parameter", 1L)); + base.ExecuteReader_binds_parameters(); + } + + [Fact] + public override void ExecuteReader_supports_CloseConnection() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 0;", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.Int64}, "c", 0L)); + base.ExecuteReader_supports_CloseConnection(); + } + + [Fact] + public override void ExecuteReader_works_when_trailing_comments() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 0; -- My favorite number", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.Int64}, "c", 0L)); + base.ExecuteReader_works_when_trailing_comments(); + } + + [Fact] + public override void ExecuteScalar_returns_DBNull_when_null() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT NULL;", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.Int64}, "c", DBNull.Value)); + base.ExecuteScalar_returns_DBNull_when_null(); + } + + [Fact] + public override void ExecuteScalar_returns_first_when_multiple_columns() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 42, 43;", + StatementResult.CreateResultSet([Tuple.Create(TypeCode.Int64, "c1"), Tuple.Create(TypeCode.Int64, "c1")], [[42L, 43L]])); + base.ExecuteScalar_returns_first_when_multiple_columns(); + } + + [Fact] + public override void ExecuteScalar_returns_real() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 3.14;", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.Float64}, "c", 3.14d)); + base.ExecuteScalar_returns_real(); + } + + [Fact] + public override void ExecuteScalar_returns_string_when_text() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 'test';", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.String}, "c", "test")); + base.ExecuteScalar_returns_string_when_text(); + } + + [Fact(Skip = "Spanner does not support multiple SQL statements in one string")] + public override void ExecuteScalar_returns_first_when_batching() + { + } + + [Fact(Skip = "Spanner does not support empty statements")] + public override void ExecuteReader_HasRows_is_false_for_comment() + { + } + + [Fact(Skip = "Spanner does not use the command text once the reader has been opened")] + public override void CommandText_throws_when_set_when_open_reader() + { + } + + [Fact(Skip = "Spanner does not need the connection after the reader has been opened")] + public override void Connection_throws_when_set_when_open_reader() + { + } + + [Fact(Skip = "Spanner does not need the connection after the reader has been opened")] + public override void Connection_throws_when_set_to_null_when_open_reader() + { + } + + [Fact(Skip = "Spanner supports multiple open readers for one command")] + public override void ExecuteReader_throws_when_reader_open() + { + } + + [Fact(Skip = "Spanner only supports one transaction per connection and therefore ignores the transaction property")] + public override void ExecuteReader_throws_when_transaction_required() + { + } + +} diff --git a/drivers/spanner-ado-net/spanner-ado-net-specification-tests/ConnectionStringTests.cs b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/ConnectionStringTests.cs new file mode 100644 index 00000000..0160687d --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/ConnectionStringTests.cs @@ -0,0 +1,19 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using AdoNet.Specification.Tests; + +namespace Google.Cloud.Spanner.DataProvider.SpecificationTests; + +public class ConnectionStringTests(DbFactoryFixture fixture) : ConnectionStringTestBase(fixture); \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-specification-tests/ConnectionTests.cs b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/ConnectionTests.cs new file mode 100644 index 00000000..eb8f2dcb --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/ConnectionTests.cs @@ -0,0 +1,19 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using AdoNet.Specification.Tests; + +namespace Google.Cloud.Spanner.DataProvider.SpecificationTests; + +public class ConnectionTests(DbFactoryFixture fixture) : ConnectionTestBase(fixture); \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-specification-tests/DataReaderTests.cs b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/DataReaderTests.cs new file mode 100644 index 00000000..028b0836 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/DataReaderTests.cs @@ -0,0 +1,107 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using AdoNet.Specification.Tests; +using Google.Cloud.SpannerLib.MockServer; +using Xunit; +using TypeCode = Google.Cloud.Spanner.V1.TypeCode; + +namespace Google.Cloud.Spanner.DataProvider.SpecificationTests; + +public class DataReaderTests(DbFactoryFixture fixture) : DataReaderTestBase(fixture) +{ + [Fact(Skip = "SpannerLib does not support multiple statements in one query string")] + public override void HasRows_works_when_batching() + { + } + + [Fact(Skip = "SpannerLib does not support multiple statements in one query string")] + public override void NextResult_works() + { + } + + [Fact(Skip = "SpannerLib does not support multiple statements in one query string")] + public override void SingleResult_returns_one_result_set() + { + } + + [Fact(Skip = "SpannerLib does not support multiple statements in one query string")] + public override void SingleRow_returns_one_result_set() + { + } + + [Fact(Skip = "Getting stats after closing a DataReader is not supported")] + public override void RecordsAffected_returns_negative_1_after_close_when_no_rows() + { + } + + [Fact(Skip = "Getting stats after closing a DataReader is not supported")] + public override void RecordsAffected_returns_negative_1_after_dispose_when_no_rows() + { + } + + public override void GetFieldValue_works_utf8_four_bytes() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT '😀';", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.String}, "c", "😀")); + base.GetFieldValue_works_utf8_four_bytes(); + } + + public override void GetString_works_utf8_four_bytes() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT '😀';", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.String}, "c", "😀")); + base.GetString_works_utf8_four_bytes(); + } + + public override void GetValue_to_string_works_utf8_four_bytes() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT '😀';", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.String}, "c", "😀")); + base.GetValue_to_string_works_utf8_four_bytes(); + } + + public override void GetFieldValue_works_utf8_three_bytes() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 'Ḁ';", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.String}, "c", "Ḁ")); + base.GetFieldValue_works_utf8_three_bytes(); + } + + public override void GetFieldValue_works_utf8_two_bytes() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 'Ä';", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.String}, "c", "Ä")); + base.GetFieldValue_works_utf8_two_bytes(); + } + + public override void GetValues_works() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 'a', NULL;", + StatementResult.CreateResultSet([Tuple.Create(TypeCode.String, "c1"), Tuple.Create(TypeCode.Int64, "c2")], [["a", DBNull.Value]])); + base.GetValues_works(); + } + + public override void Item_by_name_works() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 'test' AS Id;", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.String}, "Id", "test")); + base.Item_by_name_works(); + } + + [Fact(Skip = "The default implementation of GetTextReader returns an empty reader for null values")] + public override void GetTextReader_throws_for_null_String() + { + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-specification-tests/DbFactoryFixture.cs b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/DbFactoryFixture.cs new file mode 100644 index 00000000..7675bf42 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/DbFactoryFixture.cs @@ -0,0 +1,441 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Data; +using System.Data.Common; +using AdoNet.Specification.Tests; +using Google.Cloud.SpannerLib.MockServer; +using TypeCode = Google.Cloud.Spanner.V1.TypeCode; + +namespace Google.Cloud.Spanner.DataProvider.SpecificationTests; + +public class DbFactoryFixture : IDisposable, ISelectValueFixture, IDeleteFixture +{ + static DbFactoryFixture() + { + AppDomain.CurrentDomain.ProcessExit += (_, _) => + { + SpannerPool.CloseSpannerLib(); + }; + } + + private bool _disposed; + internal readonly SpannerMockServerFixture MockServerFixture = new (); + + public DbProviderFactory Factory => SpannerFactory.Instance; + public string ConnectionString => $"{MockServerFixture.Host}:{MockServerFixture.Port}/projects/p1/instances/i1/databases/d1;UsePlainText=true"; + + public IReadOnlyCollection SupportedDbTypes { get; } = [ + DbType.Binary, + DbType.Boolean, + DbType.Date, + DbType.DateTime, + DbType.Decimal, + DbType.Double, + DbType.Guid, + DbType.Int64, + DbType.Single, + DbType.String, + ]; + public string SelectNoRows => "select * from (select 1) where false"; + public System.Type NullValueExceptionType { get; } = typeof(InvalidCastException); + public string DeleteNoRows => "delete from foo where false"; + + public DbFactoryFixture() + { + Reset(); + } + + public void Reset() + { + MockServerFixture.SpannerMock.Reset(); + MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 1;", StatementResult.CreateSelect1ResultSet()); + MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 1", StatementResult.CreateSelect1ResultSet()); + MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT NULL;", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.Int64}, "c", DBNull.Value)); + MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 1 AS id;", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.Int64}, "id", 1)); + MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 1 AS Id;", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.Int64}, "Id", 1)); + MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 'test';", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.String}, "c", "test")); + MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 'ab¢d';", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.String}, "c", "ab¢d")); + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(SelectNoRows, + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.Int64}, "c")); + MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 42 UNION SELECT 43;", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.Int64}, "c", 42, 43)); + MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT 1 UNION SELECT 2;", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.Int64}, "c", 1, 2)); + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(DeleteNoRows, StatementResult.CreateUpdateCount(0)); + } + + public string CreateSelectSql(DbType dbType, ValueKind kind) + { + return dbType switch + { + DbType.Binary => CreateSelectSqlBinary(kind), + DbType.Boolean => CreateSelectSqlBoolean(kind), + DbType.Date => CreateSelectSqlDate(kind), + DbType.DateTime => CreateSelectSqlDateTime(kind), + DbType.Decimal => CreateSelectSqlDecimal(kind), + DbType.Double => CreateSelectSqlDouble(kind), + DbType.Guid => CreateSelectSqlGuid(kind), + DbType.Int64 => CreateSelectSqlInt64(kind), + DbType.Single => CreateSelectSqlSingle(kind), + DbType.String => CreateSelectSqlString(kind), + _ => throw new NotImplementedException("Not implemented") + }; + } + + private string CreateSelectSqlBinary(ValueKind kind) + { + var sql = "SELECT bytes_col FROM my_table;"; + switch (kind) + { + case ValueKind.Empty: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Bytes }, "bytes_col", Array.Empty())); + break; + case ValueKind.Zero: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Bytes }, "bytes_col", new byte[]{0})); + break; + case ValueKind.One: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Bytes }, "bytes_col", new byte[]{0x11})); + break; + case ValueKind.Maximum: + case ValueKind.Minimum: + case ValueKind.Null: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Bytes }, "bytes_col", DBNull.Value)); + break; + default: + throw new NotImplementedException("Not implemented"); + } + return sql; + } + private string CreateSelectSqlBoolean(ValueKind kind) + { + var sql = "SELECT bool_col FROM my_table;"; + switch (kind) + { + case ValueKind.Maximum: + case ValueKind.One: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Bool }, "bool_col", true)); + break; + case ValueKind.Empty: + case ValueKind.Minimum: + case ValueKind.Zero: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Bool }, "bool_col", false)); + break; + case ValueKind.Null: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Bool }, "bool_col", DBNull.Value)); + break; + default: + throw new NotImplementedException("Not implemented"); + } + return sql; + } + + private string CreateSelectSqlDate(ValueKind kind) + { + var sql = "SELECT date_col FROM my_table;"; + switch (kind) + { + case ValueKind.One: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Date }, "date_col", "1111-11-11")); + break; + case ValueKind.Maximum: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Date }, "date_col", "9999-12-31")); + break; + case ValueKind.Minimum: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Date }, "date_col", "0001-01-01")); + break; + case ValueKind.Zero: + case ValueKind.Empty: + case ValueKind.Null: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Date }, "date_col", DBNull.Value)); + break; + default: + throw new NotImplementedException("Not implemented"); + } + return sql; + } + + private string CreateSelectSqlDateTime(ValueKind kind) + { + var sql = "SELECT timestamp_col FROM my_table;"; + switch (kind) + { + case ValueKind.One: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Timestamp }, "timestamp_col", "1111-11-11T11:11:11.111000000Z")); + break; + case ValueKind.Maximum: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Timestamp }, "timestamp_col", "9999-12-31T23:59:59.999000000Z")); + break; + case ValueKind.Minimum: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Timestamp }, "timestamp_col", "0001-01-01T00:00:00Z")); + break; + case ValueKind.Zero: + case ValueKind.Empty: + case ValueKind.Null: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Timestamp }, "timestamp_col", DBNull.Value)); + break; + default: + throw new NotImplementedException("Not implemented"); + } + return sql; + } + + private string CreateSelectSqlDecimal(ValueKind kind) + { + var sql = "SELECT numeric_col FROM my_table;"; + switch (kind) + { + case ValueKind.Zero: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Numeric }, "numeric_col", "0")); + break; + case ValueKind.One: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Numeric }, "numeric_col", "1")); + break; + case ValueKind.Maximum: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Numeric }, "numeric_col", "99999999999999999999.999999999999999")); + break; + case ValueKind.Minimum: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Numeric }, "numeric_col", "0.000000000000001")); + break; + case ValueKind.Empty: + case ValueKind.Null: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Numeric }, "numeric_col", DBNull.Value)); + break; + default: + throw new NotImplementedException("Not implemented"); + } + return sql; + } + + private string CreateSelectSqlDouble(ValueKind kind) + { + var sql = "SELECT float64_col FROM my_table;"; + switch (kind) + { + case ValueKind.Zero: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Float64 }, "float64_col", 0.0d)); + break; + case ValueKind.One: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Float64 }, "float64_col", 1.0d)); + break; + case ValueKind.Maximum: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Float64 }, "float64_col", 1.79e308d)); + break; + case ValueKind.Minimum: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Float64 }, "float64_col", 2.23e-308d)); + break; + case ValueKind.Empty: + case ValueKind.Null: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Float64 }, "float64_col", DBNull.Value)); + break; + default: + throw new NotImplementedException("Not implemented"); + } + return sql; + } + + private string CreateSelectSqlGuid(ValueKind kind) + { + var sql = "SELECT uuid_col FROM my_table;"; + switch (kind) + { + case ValueKind.Zero: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Uuid }, "uuid_col", "00000000-0000-0000-0000-000000000000")); + break; + case ValueKind.One: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Uuid }, "uuid_col", "11111111-1111-1111-1111-111111111111")); + break; + case ValueKind.Maximum: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Uuid }, "uuid_col", "ccddeeff-aabb-8899-7766-554433221100")); + break; + case ValueKind.Minimum: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Uuid }, "uuid_col", "33221100-5544-7766-9988-aabbccddeeff")); + break; + case ValueKind.Empty: + case ValueKind.Null: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Uuid }, "uuid_col", DBNull.Value)); + break; + default: + throw new NotImplementedException("Not implemented"); + } + return sql; + } + + private string CreateSelectSqlInt64(ValueKind kind) + { + var sql = "SELECT int64_col FROM my_table;"; + switch (kind) + { + case ValueKind.Zero: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Int64 }, "int64_col", 0L)); + break; + case ValueKind.One: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Int64 }, "int64_col", 1L)); + break; + case ValueKind.Maximum: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Int64 }, "int64_col", long.MaxValue)); + break; + case ValueKind.Minimum: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Int64 }, "int64_col", long.MinValue)); + break; + case ValueKind.Empty: + case ValueKind.Null: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Int64 }, "int64_col", DBNull.Value)); + break; + default: + throw new NotImplementedException("Not implemented"); + } + return sql; + } + + private string CreateSelectSqlSingle(ValueKind kind) + { + var sql = "SELECT float32_col FROM my_table;"; + switch (kind) + { + case ValueKind.Zero: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Float32 }, "float32_col", 0.0f)); + break; + case ValueKind.One: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Float32 }, "float32_col", 1.0f)); + break; + case ValueKind.Maximum: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Float32 }, "float32_col", 3.40e38f)); + break; + case ValueKind.Minimum: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Float32 }, "float32_col", 1.18e-38f)); + break; + case ValueKind.Empty: + case ValueKind.Null: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.Float32 }, "float32_col", DBNull.Value)); + break; + default: + throw new NotImplementedException("Not implemented"); + } + return sql; + } + + private string CreateSelectSqlString(ValueKind kind) + { + var sql = "SELECT string_col FROM my_table;"; + switch (kind) + { + case ValueKind.Zero: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.String }, "string_col", "0")); + break; + case ValueKind.One: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.String }, "string_col", "1")); + break; + case ValueKind.Empty: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.String }, "string_col", "")); + break; + case ValueKind.Maximum: + case ValueKind.Minimum: + case ValueKind.Null: + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type { Code = TypeCode.String }, "string_col", DBNull.Value)); + break; + default: + throw new NotImplementedException("Not implemented"); + } + return sql; + } + + public string CreateSelectSql(byte[] value) + { + var sql = "SELECT bytes_col FROM my_table;"; + MockServerFixture.SpannerMock.AddOrUpdateStatementResult(sql, + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.Bytes}, "bytes_col", value)); + + return sql; + } + + protected void MarkDisposed() + { + _disposed = true; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + try + { + MockServerFixture.Dispose(); + // var source = new CancellationTokenSource(); + // source.CancelAfter(1000); + // Task.Run(() => SpannerPool.CloseSpannerLibWhenAllConnectionsClosedAsync(source.Token), source.Token).Wait(source.Token); + } + finally + { + _disposed = true; + } + } +} diff --git a/drivers/spanner-ado-net/spanner-ado-net-specification-tests/DbFactoryTests.cs b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/DbFactoryTests.cs new file mode 100644 index 00000000..7a34fecc --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/DbFactoryTests.cs @@ -0,0 +1,19 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using AdoNet.Specification.Tests; + +namespace Google.Cloud.Spanner.DataProvider.SpecificationTests; + +public class DbFactoryTests(DbFactoryFixture fixture) : DbFactoryTestBase(fixture); \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-specification-tests/DbProviderFactoryTests.cs b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/DbProviderFactoryTests.cs new file mode 100644 index 00000000..6ddf7eee --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/DbProviderFactoryTests.cs @@ -0,0 +1,19 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using AdoNet.Specification.Tests; + +namespace Google.Cloud.Spanner.DataProvider.SpecificationTests; + +public class DbProviderFactoryTests(DbFactoryFixture fixture) : DbProviderFactoryTestBase(fixture); \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-specification-tests/GetValueConversionTests.cs b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/GetValueConversionTests.cs new file mode 100644 index 00000000..4f4870f0 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/GetValueConversionTests.cs @@ -0,0 +1,138 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Data; +using System.Globalization; +using AdoNet.Specification.Tests; + +namespace Google.Cloud.Spanner.DataProvider.SpecificationTests; + +public class GetValueConversionTests(DbFactoryFixture fixture) : GetValueConversionTestBase(fixture) +{ + // Spanner uses DateOnly for DATE columns. + public override void GetFieldType_for_Date() => TestGetFieldType(DbType.Date, ValueKind.One, typeof(DateOnly)); + + public override void GetValue_for_Date() => TestGetValue(DbType.Date, ValueKind.One, new DateOnly(1111, 11, 11)); + + + // Spanner allows string values to be cast to numerical values. + public override void GetDecimal_throws_for_zero_String() => TestGetValue(DbType.String, ValueKind.Zero, x => x.GetDecimal(0), 0.0m); + + public override void GetDecimal_throws_for_one_String() => TestGetValue(DbType.String, ValueKind.One, x => x.GetDecimal(0), 1.0m); + + public override void GetDouble_throws_for_zero_String() => TestGetValue(DbType.String, ValueKind.Zero, x => x.GetDouble(0), 0.0d); + + public override void GetDouble_throws_for_one_String() => TestGetValue(DbType.String, ValueKind.One, x => x.GetDouble(0), 1.0d); + + public override void GetDouble_throws_for_zero_String_with_GetFieldValue() => TestGetValue(DbType.String, ValueKind.Zero, x => x.GetFieldValue(0), 0.0d); + + public override async Task GetDouble_throws_for_zero_String_with_GetFieldValueAsync() => await TestGetValueAsync(DbType.String, ValueKind.Zero, async x => await x.GetFieldValueAsync(0), 0.0d); + + public override void GetFloat_throws_for_one_String() => TestGetValue(DbType.String, ValueKind.One, x => x.GetFloat(0), 1.0f); + + public override void GetFloat_throws_for_zero_String() => TestGetValue(DbType.String, ValueKind.Zero, x => x.GetFloat(0), 0.0f); + + public override void GetFloat_throws_for_zero_String_with_GetFieldValue() => TestGetValue(DbType.String, ValueKind.Zero, x => x.GetFieldValue(0), 0.0f); + + public override async Task GetFloat_throws_for_zero_String_with_GetFieldValueAsync() => await TestGetValueAsync(DbType.String, ValueKind.Zero, async x => await x.GetFieldValueAsync(0), 0.0f); + + public override void GetInt16_throws_for_one_String() => TestGetValue(DbType.String, ValueKind.One, x => x.GetInt16(0), (short) 1); + + public override void GetInt16_throws_for_zero_String() => TestGetValue(DbType.String, ValueKind.Zero, x => x.GetInt16(0), (short) 0); + + public override void GetInt16_throws_for_zero_String_with_GetFieldValue() => TestGetValue(DbType.String, ValueKind.Zero, x => x.GetFieldValue(0), (short) 0); + + public override async Task GetInt16_throws_for_zero_String_with_GetFieldValueAsync() => await TestGetValueAsync(DbType.String, ValueKind.Zero, async x => await x.GetFieldValueAsync(0), (short) 0); + + public override void GetInt32_throws_for_one_String() => TestGetValue(DbType.String, ValueKind.One, x => x.GetInt32(0), 1); + + public override void GetInt32_throws_for_zero_String() => TestGetValue(DbType.String, ValueKind.Zero, x => x.GetInt32(0), 0); + + public override void GetInt32_throws_for_zero_String_with_GetFieldValue() => TestGetValue(DbType.String, ValueKind.Zero, x => x.GetFieldValue(0), 0); + + public override async Task GetInt32_throws_for_zero_String_with_GetFieldValueAsync() => await TestGetValueAsync(DbType.String, ValueKind.Zero, async x => await x.GetFieldValueAsync(0), 0); + + public override void GetInt64_throws_for_one_String() => TestGetValue(DbType.String, ValueKind.One, x => x.GetInt64(0), 1L); + + public override void GetInt64_throws_for_zero_String() => TestGetValue(DbType.String, ValueKind.Zero, x => x.GetInt64(0), 0L); + + public override void GetInt64_throws_for_zero_String_with_GetFieldValue() => TestGetValue(DbType.String, ValueKind.Zero, x => x.GetFieldValue(0), 0L); + + public override async Task GetInt64_throws_for_zero_String_with_GetFieldValueAsync() => await TestGetValueAsync(DbType.String, ValueKind.Zero, async x => await x.GetFieldValueAsync(0), 0L); + + public override void GetString_throws_for_maximum_Boolean() => TestGetValue(DbType.Boolean, ValueKind.Maximum, x => x.GetString(0), "True"); + + public override void GetString_throws_for_maximum_Decimal() => TestGetValue(DbType.Decimal, ValueKind.Maximum, x => x.GetString(0), "99999999999999999999.999999999999999"); + + public override void GetString_throws_for_maximum_Double() => TestGetValue(DbType.Double, ValueKind.Maximum, x => x.GetString(0), "1.79E+308"); + + public override void GetString_throws_for_maximum_Int64() => TestGetValue(DbType.Int64, ValueKind.Maximum, x => x.GetString(0), long.MaxValue.ToString(CultureInfo.InvariantCulture)); + + public override void GetString_throws_for_maximum_Single() => TestGetValue(DbType.Single, ValueKind.Maximum, x => x.GetString(0), 3.40e38f.ToString(CultureInfo.InvariantCulture)); + + public override void GetString_throws_for_minimum_Boolean() => TestGetValue(DbType.Boolean, ValueKind.Minimum, x => x.GetString(0), "False"); + + public override void GetString_throws_for_minimum_Decimal() => TestGetValue(DbType.Decimal, ValueKind.Minimum, x => x.GetString(0), "0.000000000000001"); + + public override void GetString_throws_for_minimum_Double() => TestGetValue(DbType.Double, ValueKind.Minimum, x => x.GetString(0), "2.23E-308"); + + public override void GetString_throws_for_minimum_Int64() => TestGetValue(DbType.Int64, ValueKind.Minimum, x => x.GetString(0), long.MinValue.ToString(CultureInfo.InvariantCulture)); + + public override void GetString_throws_for_minimum_Single() => TestGetValue(DbType.Single, ValueKind.Minimum, x => x.GetString(0), "1.18E-38"); + + public override void GetString_throws_for_one_Boolean() => TestGetValue(DbType.Boolean, ValueKind.One, x => x.GetString(0), "True"); + + public override void GetString_throws_for_one_Decimal() => TestGetValue(DbType.Decimal, ValueKind.One, x => x.GetString(0), "1"); + + public override void GetString_throws_for_one_Double() => TestGetValue(DbType.Double, ValueKind.One, x => x.GetString(0), "1"); + + public override void GetString_throws_for_one_Guid() => TestGetValue(DbType.Guid, ValueKind.One, x => x.GetString(0), "11111111-1111-1111-1111-111111111111"); + + public override void GetString_throws_for_one_Int64() => TestGetValue(DbType.Int64, ValueKind.One, x => x.GetString(0), "1"); + + public override void GetString_throws_for_one_Single() => TestGetValue(DbType.Single, ValueKind.One, x => x.GetString(0), "1"); + + public override void GetString_throws_for_zero_Boolean() => TestGetValue(DbType.Boolean, ValueKind.Zero, x => x.GetString(0), "False"); + + public override void GetString_throws_for_zero_Decimal() => TestGetValue(DbType.Decimal, ValueKind.Zero, x => x.GetString(0), "0"); + + public override void GetString_throws_for_zero_Double() => TestGetValue(DbType.Double, ValueKind.Zero, x => x.GetString(0), "0"); + + public override void GetString_throws_for_zero_Guid() => TestGetValue(DbType.Guid, ValueKind.Zero, x => x.GetString(0), "00000000-0000-0000-0000-000000000000"); + + public override void GetString_throws_for_zero_Int64() => TestGetValue(DbType.Int64, ValueKind.Zero, x => x.GetString(0), "0"); + + public override void GetString_throws_for_zero_Single() => TestGetValue(DbType.Single, ValueKind.Zero, x => x.GetString(0), "0"); + + public override void GetDouble_throws_for_one_String_with_GetFieldValue() => TestGetValue(DbType.String, ValueKind.One, x => x.GetFieldValue(0), 1.0d); + + public override async Task GetDouble_throws_for_one_String_with_GetFieldValueAsync() => await TestGetValueAsync(DbType.String, ValueKind.One, async x => await x.GetFieldValueAsync(0), 1.0d); + + public override void GetFloat_throws_for_one_String_with_GetFieldValue() => TestGetValue(DbType.String, ValueKind.One, x => x.GetFieldValue(0), 1.0f); + + public override async Task GetFloat_throws_for_one_String_with_GetFieldValueAsync() => await TestGetValueAsync(DbType.String, ValueKind.One, async x => await x.GetFieldValueAsync(0), 1.0f); + + public override void GetInt16_throws_for_one_String_with_GetFieldValue() => TestGetValue(DbType.String, ValueKind.One, x => x.GetFieldValue(0), (short) 1); + + public override async Task GetInt16_throws_for_one_String_with_GetFieldValueAsync() => await TestGetValueAsync(DbType.String, ValueKind.One, async x => await x.GetFieldValueAsync(0), (short) 1); + + public override void GetInt32_throws_for_one_String_with_GetFieldValue() => TestGetValue(DbType.String, ValueKind.One, x => x.GetFieldValue(0), 1); + + public override async Task GetInt32_throws_for_one_String_with_GetFieldValueAsync() => await TestGetValueAsync(DbType.String, ValueKind.One, async x => await x.GetFieldValueAsync(0), 1); + + public override void GetInt64_throws_for_one_String_with_GetFieldValue() => TestGetValue(DbType.String, ValueKind.One, x => x.GetFieldValue(0), 1L); + + public override async Task GetInt64_throws_for_one_String_with_GetFieldValueAsync() => await TestGetValueAsync(DbType.String, ValueKind.One, async x => await x.GetFieldValueAsync(0), 1L); + +} diff --git a/drivers/spanner-ado-net/spanner-ado-net-specification-tests/ParameterTests.cs b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/ParameterTests.cs new file mode 100644 index 00000000..1a33a87b --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/ParameterTests.cs @@ -0,0 +1,83 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using AdoNet.Specification.Tests; +using Google.Cloud.Spanner.V1; +using Google.Cloud.SpannerLib.MockServer; +using Xunit; +using TypeCode = Google.Cloud.Spanner.V1.TypeCode; + +namespace Google.Cloud.Spanner.DataProvider.SpecificationTests; + +public class ParameterTests(DbFactoryFixture fixture) : ParameterTestBase(fixture) +{ + protected override Task OnInitializeAsync() + { + Fixture.Reset(); + return base.OnInitializeAsync(); + } + + [Fact(Skip = "Spanner assumes that it is a positional parameter if it has no name")] + public override void Bind_requires_set_name() + { + } + + [Fact(Skip = "Unknown parameters are converted to strings")] + public override void Bind_throws_when_unknown() + { + } + + [Fact] + public override void Bind_works_with_byte_array() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT @Parameter;", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.Bytes}, "Parameter", new byte[]{1,2,3,4})); + base.Bind_works_with_byte_array(); + + var requests = Fixture.MockServerFixture.SpannerMock.Requests.OfType(); + var request = Assert.Single(requests); + Assert.Equal(Convert.ToBase64String(new byte[]{1,2,3,4}), request.Params.Fields["Parameter"].StringValue); + // The parameter value should be sent as an untyped string. + Assert.False(request.ParamTypes.ContainsKey("Parameter")); + } + + [Fact] + public override void Bind_works_with_stream() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT @Parameter;", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.Bytes}, "Parameter", new byte[]{1,2,3,4})); + base.Bind_works_with_stream(); + + var requests = Fixture.MockServerFixture.SpannerMock.Requests.OfType(); + var request = Assert.Single(requests); + Assert.Equal(Convert.ToBase64String(new byte[]{1,2,3,4}), request.Params.Fields["Parameter"].StringValue); + // The parameter value should be sent as an untyped string. + Assert.False(request.ParamTypes.ContainsKey("Parameter")); + } + + [Fact] + public override void Bind_works_with_string() + { + Fixture.MockServerFixture.SpannerMock.AddOrUpdateStatementResult("SELECT @Parameter;", + StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.String}, "Parameter", "test")); + base.Bind_works_with_string(); + + var requests = Fixture.MockServerFixture.SpannerMock.Requests.OfType(); + var request = Assert.Single(requests); + Assert.Equal("test", request.Params.Fields["Parameter"].StringValue); + // The parameter value should be sent as an untyped string. + Assert.False(request.ParamTypes.ContainsKey("Parameter")); + } + +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-specification-tests/README.md b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/README.md new file mode 100644 index 00000000..9bbfd808 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/README.md @@ -0,0 +1,5 @@ +# Spanner ADO.NET Data Provider Specification Tests + +Specification tests for ADO.NET Data Provider for Spanner. + +__ALPHA: Not for production use__ diff --git a/drivers/spanner-ado-net/spanner-ado-net-specification-tests/TransactionTests.cs b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/TransactionTests.cs new file mode 100644 index 00000000..faf8b719 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/TransactionTests.cs @@ -0,0 +1,19 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using AdoNet.Specification.Tests; + +namespace Google.Cloud.Spanner.DataProvider.SpecificationTests; + +public class TransactionTests(DbFactoryFixture fixture) : TransactionTestBase(fixture); \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-specification-tests/appsettings.json b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/appsettings.json new file mode 100644 index 00000000..2a8537d3 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Microsoft": "Warning" + } + } +} diff --git a/drivers/spanner-ado-net/spanner-ado-net-specification-tests/spanner-ado-net-specification-tests.csproj b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/spanner-ado-net-specification-tests.csproj new file mode 100644 index 00000000..ca134215 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-specification-tests/spanner-ado-net-specification-tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + Google.Cloud.Spanner.DataProvider.SpecificationTests + enable + enable + + false + true + Google.Cloud.Spanner.DataProvider.SpecificationTests + default + + + + + + + + + + + + + + + + + + + diff --git a/drivers/spanner-ado-net/spanner-ado-net-tests/AbstractMockServerTests.cs b/drivers/spanner-ado-net/spanner-ado-net-tests/AbstractMockServerTests.cs new file mode 100644 index 00000000..d4db3812 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-tests/AbstractMockServerTests.cs @@ -0,0 +1,57 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using Google.Cloud.SpannerLib.MockServer; + +namespace Google.Cloud.Spanner.DataProvider.Tests; + +public abstract class AbstractMockServerTests +{ + static AbstractMockServerTests() + { + AppDomain.CurrentDomain.ProcessExit += (_, _) => + { + SpannerPool.CloseSpannerLib(); + }; + } + + protected SpannerMockServerFixture Fixture; + + protected string ConnectionString => $"{Fixture.Host}:{Fixture.Port}/projects/p1/instances/i1/databases/d1;UsePlainText=true"; + + [OneTimeSetUp] + public void Setup() + { + Fixture = new SpannerMockServerFixture(); + } + + [OneTimeTearDown] + public void Teardown() + { + Fixture.Dispose(); + } + + [SetUp] + public void SetupResults() + { + Fixture.SpannerMock.AddOrUpdateStatementResult("SELECT 1", StatementResult.CreateSelect1ResultSet()); + } + + [TearDown] + public void Reset() + { + Fixture.SpannerMock.Reset(); + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-tests/BasicTests.cs b/drivers/spanner-ado-net/spanner-ado-net-tests/BasicTests.cs new file mode 100644 index 00000000..613b443b --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-tests/BasicTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Data.Common; +using System.Linq; +using System.Text.Json; +using Google.Cloud.Spanner.V1; +using Google.Cloud.SpannerLib.MockServer; + +namespace Google.Cloud.Spanner.DataProvider.Tests; + +public class BasicTests : AbstractMockServerTests +{ + [Test] + public void TestOpenConnection() + { + var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + connection.Open(); + connection.Close(); + } + + [Test] + public void TestExecuteQuery() + { + using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT 1"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + Assert.That(reader.GetInt64(0), Is.EqualTo(1)); + } + } + + [Test] + public void TestExecuteParameterizedQuery() + { + Fixture.SpannerMock.AddOrUpdateStatementResult("SELECT $1", StatementResult.CreateSelect1ResultSet()); + using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT $1"; + var param = cmd.CreateParameter(); + param.ParameterName = "p1"; + param.Value = 1; + cmd.Parameters.Add(param); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + Assert.That(reader.GetInt64(0), Is.EqualTo(1)); + } + } + + [Test] + public void TestInsertAllDataTypes() + { + var sql = "insert into all_types (col_bool, col_bytes, col_date, col_interval, col_json, col_int64, col_float32, col_float64, col_numeric, col_string, col_timestamp) " + + "values (@col_bool, @col_bytes, @col_date, @col_interval, @col_json, @col_int64, @col_float32, @col_float64, @col_numeric, @col_string, @col_timestamp)"; + Fixture.SpannerMock.AddOrUpdateStatementResult(sql, StatementResult.CreateUpdateCount(1)); + + using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + AddParameter(cmd, "col_bool", true); + AddParameter(cmd, "col_bytes", new byte[] { 1, 2, 3 }); + AddParameter(cmd, "col_date", new DateOnly(2025, 8, 25)); + AddParameter(cmd, "col_interval", TimeSpan.FromHours(1)); + AddParameter(cmd, "col_json", JsonDocument.Parse("{\"key\":\"value\"}")); + AddParameter(cmd, "col_int64", 10); + AddParameter(cmd, "col_float32", 3.14f); + AddParameter(cmd, "col_float64", 3.14d); + AddParameter(cmd, "col_numeric", 10.1m); + AddParameter(cmd, "col_string", "hello"); + AddParameter(cmd, "col_timestamp", DateTime.Parse("2025-08-25T16:30:55Z")); + + var updateCount = cmd.ExecuteNonQuery(); + Assert.That(updateCount, Is.EqualTo(1)); + + var requests = Fixture.SpannerMock.Requests.OfType().ToList(); + Assert.That(requests, Has.Count.EqualTo(1)); + var request = requests.First(); + Assert.That(request.Params.Fields, Has.Count.EqualTo(11)); + Assert.That(request.Params.Fields["col_bool"].BoolValue, Is.EqualTo(true)); + Assert.That(request.Params.Fields["col_bytes"].StringValue, Is.EqualTo(Convert.ToBase64String(new byte[]{1,2,3}))); + Assert.That(request.Params.Fields["col_date"].StringValue, Is.EqualTo("2025-08-25")); + Assert.That(request.Params.Fields["col_interval"].StringValue, Is.EqualTo("PT1H")); + Assert.That(request.Params.Fields["col_int64"].StringValue, Is.EqualTo("10")); + Assert.That(request.Params.Fields["col_float32"].NumberValue, Is.EqualTo(3.14f)); + Assert.That(request.Params.Fields["col_float64"].NumberValue, Is.EqualTo(3.14d)); + Assert.That(request.Params.Fields["col_numeric"].StringValue, Is.EqualTo("10.1")); + Assert.That(request.Params.Fields["col_string"].StringValue, Is.EqualTo("hello")); + Assert.That(request.Params.Fields["col_timestamp"].StringValue, Is.EqualTo("2025-08-25T16:30:55.0000000Z")); + + Assert.That(request.ParamTypes.Count, Is.EqualTo(0)); + } + + private void AddParameter(DbCommand command, string name, object value) + { + var param = command.CreateParameter(); + param.ParameterName = name; + param.Value = value; + command.Parameters.Add(param); + } +} diff --git a/drivers/spanner-ado-net/spanner-ado-net-tests/BatchTests.cs b/drivers/spanner-ado-net/spanner-ado-net-tests/BatchTests.cs new file mode 100644 index 00000000..6590020c --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-tests/BatchTests.cs @@ -0,0 +1,272 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Data.Common; +using System.Text.Json; +using Google.Cloud.Spanner.V1; +using Google.Cloud.SpannerLib.MockServer; +using Google.Protobuf.WellKnownTypes; + +namespace Google.Cloud.Spanner.DataProvider.Tests; + +public class BatchTests : AbstractMockServerTests +{ + [TestCase(1, false)] + [TestCase(2, false)] + [TestCase(5, false)] + [TestCase(1, true)] + [TestCase(2, true)] + [TestCase(5, true)] + public async Task TestAllParameterTypes(int numCommands, bool executeAsync) + { + await using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + await connection.OpenAsync(); + + const string insert = "insert into my_table values (@p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9, @p10, @p11, @p12)"; + Fixture.SpannerMock.AddOrUpdateStatementResult(insert, StatementResult.CreateUpdateCount(1)); + + await using var batch = connection.CreateBatch(); + + for (var i = 0; i < numCommands; i++) + { + var command = batch.CreateBatchCommand(); + command.CommandText = insert; + // TODO: + // - PROTO + // - STRUCT + AddParameter(command, "p1", true); + AddParameter(command, "p2", new byte[] { 1, 2, 3 }); + AddParameter(command, "p3", new DateOnly(2025, 10, 2)); + AddParameter(command, "p4", new TimeSpan(1, 2, 3, 4, 5, 6)); + AddParameter(command, "p5", JsonDocument.Parse("{\"key\": \"value\"}")); + AddParameter(command, "p6", 9.99m); + AddParameter(command, "p7", "test"); + AddParameter(command, "p8", new DateTime(2025, 10, 2, 15, 57, 31, 999, DateTimeKind.Utc)); + AddParameter(command, "p9", Guid.Parse("5555990c-b259-4539-bd22-5a9293cf10ac")); + AddParameter(command, "p10", 3.14d); + AddParameter(command, "p11", 3.14f); + AddParameter(command, "p12", DBNull.Value); + + AddParameter(command, "p13", new bool?[] { true, false, null }); + AddParameter(command, "p14", new byte[]?[] { [1, 2, 3], null }); + AddParameter(command, "p15", new DateOnly?[] { new DateOnly(2025, 10, 2), null }); + AddParameter(command, "p16", new TimeSpan?[] { new TimeSpan(1, 2, 3, 4, 5, 6), null }); + AddParameter(command, "p17", new[] { JsonDocument.Parse("{\"key\": \"value\"}"), null }); + AddParameter(command, "p18", new decimal?[] { 9.99m, null }); + AddParameter(command, "p19", new[] { "test", null }); + AddParameter(command, "p20", + new DateTime?[] { new DateTime(2025, 10, 2, 15, 57, 31, 999, DateTimeKind.Utc), null }); + AddParameter(command, "p21", new Guid?[] { Guid.Parse("5555990c-b259-4539-bd22-5a9293cf10ac"), null }); + AddParameter(command, "p22", new double?[] { 3.14d, null }); + AddParameter(command, "p23", new float?[] { 3.14f, null }); + + batch.BatchCommands.Add(command); + } + + int affected; + if (executeAsync) + { + affected = await batch.ExecuteNonQueryAsync(); + } + else + { + // ReSharper disable once MethodHasAsyncOverload + affected = batch.ExecuteNonQuery(); + } + Assert.That(affected, Is.EqualTo(numCommands)); + foreach (var command in batch.BatchCommands) + { + Assert.That(command.RecordsAffected, Is.EqualTo(1)); + } + + var requests = Fixture.SpannerMock.Requests.ToList(); + Assert.That(requests.OfType().Count, Is.EqualTo(1)); + Assert.That(requests.OfType().Count, Is.EqualTo(1)); + var request = requests.OfType().Single(); + Assert.That(request.Statements.Count, Is.EqualTo(numCommands)); + foreach (var statement in request.Statements) + { + // The driver does not send any parameter types, unless it is explicitly asked to do so. + Assert.That(statement.ParamTypes.Count, Is.EqualTo(0)); + Assert.That(statement.Params.Fields.Count, Is.EqualTo(23)); + var fields = statement.Params.Fields; + Assert.That(fields["p1"].HasBoolValue, Is.True); + Assert.That(fields["p1"].BoolValue, Is.True); + Assert.That(fields["p2"].HasStringValue, Is.True); + Assert.That(fields["p2"].StringValue, Is.EqualTo(Convert.ToBase64String(new byte[] { 1, 2, 3 }))); + Assert.That(fields["p3"].HasStringValue, Is.True); + Assert.That(fields["p3"].StringValue, Is.EqualTo("2025-10-02")); + Assert.That(fields["p4"].HasStringValue, Is.True); + Assert.That(fields["p4"].StringValue, Is.EqualTo("P1DT2H3M4.005006S")); + Assert.That(fields["p5"].HasStringValue, Is.True); + Assert.That(fields["p5"].StringValue, Is.EqualTo("{\"key\": \"value\"}")); + Assert.That(fields["p6"].HasStringValue, Is.True); + Assert.That(fields["p6"].StringValue, Is.EqualTo("9.99")); + Assert.That(fields["p7"].HasStringValue, Is.True); + Assert.That(fields["p7"].StringValue, Is.EqualTo("test")); + Assert.That(fields["p8"].HasStringValue, Is.True); + Assert.That(fields["p8"].StringValue, Is.EqualTo("2025-10-02T15:57:31.9990000Z")); + Assert.That(fields["p9"].HasStringValue, Is.True); + Assert.That(fields["p9"].StringValue, Is.EqualTo("5555990c-b259-4539-bd22-5a9293cf10ac")); + Assert.That(fields["p10"].HasNumberValue, Is.True); + Assert.That(fields["p10"].NumberValue, Is.EqualTo(3.14d)); + Assert.That(fields["p11"].HasNumberValue, Is.True); + Assert.That(fields["p11"].NumberValue, Is.EqualTo(3.14f)); + Assert.That(fields["p12"].HasNullValue, Is.True); + + Assert.That(fields["p13"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p13"].ListValue.Values.Count, Is.EqualTo(3)); + Assert.That(fields["p13"].ListValue.Values[0].HasBoolValue, Is.True); + Assert.That(fields["p13"].ListValue.Values[0].BoolValue, Is.True); + Assert.That(fields["p13"].ListValue.Values[1].HasBoolValue, Is.True); + Assert.That(fields["p13"].ListValue.Values[1].BoolValue, Is.False); + Assert.That(fields["p13"].ListValue.Values[2].HasNullValue, Is.True); + + Assert.That(fields["p14"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p14"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p14"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p14"].ListValue.Values[0].StringValue, + Is.EqualTo(Convert.ToBase64String(new byte[] { 1, 2, 3 }))); + Assert.That(fields["p14"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p15"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p15"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p15"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p15"].ListValue.Values[0].StringValue, Is.EqualTo("2025-10-02")); + Assert.That(fields["p15"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p16"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p16"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p16"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p16"].ListValue.Values[0].StringValue, Is.EqualTo("P1DT2H3M4.005006S")); + Assert.That(fields["p16"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p17"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p17"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p17"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p17"].ListValue.Values[0].StringValue, Is.EqualTo("{\"key\": \"value\"}")); + Assert.That(fields["p17"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p18"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p18"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p18"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p18"].ListValue.Values[0].StringValue, Is.EqualTo("9.99")); + Assert.That(fields["p18"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p19"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p19"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p19"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p19"].ListValue.Values[0].StringValue, Is.EqualTo("test")); + Assert.That(fields["p19"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p20"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p20"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p20"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p20"].ListValue.Values[0].StringValue, Is.EqualTo("2025-10-02T15:57:31.9990000Z")); + Assert.That(fields["p20"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p21"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p21"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p21"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p21"].ListValue.Values[0].StringValue, + Is.EqualTo("5555990c-b259-4539-bd22-5a9293cf10ac")); + Assert.That(fields["p21"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p22"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p22"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p22"].ListValue.Values[0].HasNumberValue, Is.True); + Assert.That(fields["p22"].ListValue.Values[0].NumberValue, Is.EqualTo(3.14d)); + Assert.That(fields["p22"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p23"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p23"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p23"].ListValue.Values[0].HasNumberValue, Is.True); + Assert.That(fields["p23"].ListValue.Values[0].NumberValue, Is.EqualTo(3.14f)); + Assert.That(fields["p23"].ListValue.Values[1].HasNullValue, Is.True); + } + } + + [TestCase(true)] + [TestCase(false)] + public async Task TestEmptyBatch(bool executeAsync) + { + await using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + await connection.OpenAsync(); + + await using var batch = connection.CreateBatch(); + int affected; + if (executeAsync) + { + affected = await batch.ExecuteNonQueryAsync(); + } + else + { + // ReSharper disable once MethodHasAsyncOverload + affected = batch.ExecuteNonQuery(); + } + Assert.That(affected, Is.EqualTo(0)); + } + + [TestCase(true)] + [TestCase(false)] + public async Task TestExecuteReader(bool executeAsync) + { + await using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + await connection.OpenAsync(); + + await using var batch = connection.CreateBatch(); + var command = batch.CreateBatchCommand(); + command.CommandText = "select * from my_table"; + if (executeAsync) + { + Assert.ThrowsAsync(() => batch.ExecuteReaderAsync()); + } + else + { + Assert.Throws(() => batch.ExecuteReader()); + } + } + + [TestCase(true)] + [TestCase(false)] + public async Task TestExecuteScalar(bool executeAsync) + { + await using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + await connection.OpenAsync(); + + await using var batch = connection.CreateBatch(); + var command = batch.CreateBatchCommand(); + command.CommandText = "select * from my_table"; + if (executeAsync) + { + Assert.ThrowsAsync(() => batch.ExecuteScalarAsync()); + } + else + { + Assert.Throws(() => batch.ExecuteScalar()); + } + } + + private static void AddParameter(DbBatchCommand command, string name, object? value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + command.Parameters.Add(parameter); + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-tests/CommandTests.cs b/drivers/spanner-ado-net/spanner-ado-net-tests/CommandTests.cs new file mode 100644 index 00000000..02df9764 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-tests/CommandTests.cs @@ -0,0 +1,178 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Data.Common; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Google.Cloud.Spanner.V1; +using Google.Cloud.SpannerLib.MockServer; +using Google.Protobuf.WellKnownTypes; + +namespace Google.Cloud.Spanner.DataProvider.Tests; + +public class CommandTests : AbstractMockServerTests +{ + [Test] + public async Task TestAllParameterTypes() + { + var insert = "insert into my_table values (@p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9, @p10, @p11, @p12)"; + Fixture.SpannerMock.AddOrUpdateStatementResult(insert, StatementResult.CreateUpdateCount(1)); + + await using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = insert; + // TODO: + // - PROTO + // - STRUCT + AddParameter(command, "p1", true); + AddParameter(command, "p2", new byte[] {1, 2, 3}); + AddParameter(command, "p3", new DateOnly(2025, 10, 2)); + AddParameter(command, "p4", new TimeSpan(1, 2, 3, 4, 5, 6)); + AddParameter(command, "p5", JsonDocument.Parse("{\"key\": \"value\"}")); + AddParameter(command, "p6", 9.99m); + AddParameter(command, "p7", "test"); + AddParameter(command, "p8", new DateTime(2025, 10, 2, 15, 57, 31, 999, DateTimeKind.Utc)); + AddParameter(command, "p9", Guid.Parse("5555990c-b259-4539-bd22-5a9293cf10ac")); + AddParameter(command, "p10", 3.14d); + AddParameter(command, "p11", 3.14f); + AddParameter(command, "p12", DBNull.Value); + + AddParameter(command, "p13", new bool?[]{true, false, null}); + AddParameter(command, "p14", new byte[]?[]{ [1,2,3], null }); + AddParameter(command, "p15", new DateOnly?[] { new DateOnly(2025, 10, 2), null }); + AddParameter(command, "p16", new TimeSpan?[] { new TimeSpan(1, 2, 3, 4, 5, 6), null }); + AddParameter(command, "p17", new [] { JsonDocument.Parse("{\"key\": \"value\"}"), null }); + AddParameter(command, "p18", new decimal?[] { 9.99m, null }); + AddParameter(command, "p19", new [] { "test", null }); + AddParameter(command, "p20", new DateTime?[] { new DateTime(2025, 10, 2, 15, 57, 31, 999, DateTimeKind.Utc), null }); + AddParameter(command, "p21", new Guid?[] { Guid.Parse("5555990c-b259-4539-bd22-5a9293cf10ac"), null }); + AddParameter(command, "p22", new double?[] { 3.14d, null }); + AddParameter(command, "p23", new float?[] { 3.14f, null }); + + await command.ExecuteNonQueryAsync(); + + var requests = Fixture.SpannerMock.Requests.ToList(); + Assert.That(requests.OfType().Count, Is.EqualTo(1)); + Assert.That(requests.OfType().Count, Is.EqualTo(1)); + var request = requests.OfType().Single(); + // The driver does not send any parameter types, unless it is explicitly asked to do so. + Assert.That(request.ParamTypes.Count, Is.EqualTo(0)); + Assert.That(request.Params.Fields.Count, Is.EqualTo(23)); + var fields = request.Params.Fields; + Assert.That(fields["p1"].HasBoolValue, Is.True); + Assert.That(fields["p1"].BoolValue, Is.True); + Assert.That(fields["p2"].HasStringValue, Is.True); + Assert.That(fields["p2"].StringValue, Is.EqualTo(Convert.ToBase64String(new byte[]{1,2,3}))); + Assert.That(fields["p3"].HasStringValue, Is.True); + Assert.That(fields["p3"].StringValue, Is.EqualTo("2025-10-02")); + Assert.That(fields["p4"].HasStringValue, Is.True); + Assert.That(fields["p4"].StringValue, Is.EqualTo("P1DT2H3M4.005006S")); + Assert.That(fields["p5"].HasStringValue, Is.True); + Assert.That(fields["p5"].StringValue, Is.EqualTo("{\"key\": \"value\"}")); + Assert.That(fields["p6"].HasStringValue, Is.True); + Assert.That(fields["p6"].StringValue, Is.EqualTo("9.99")); + Assert.That(fields["p7"].HasStringValue, Is.True); + Assert.That(fields["p7"].StringValue, Is.EqualTo("test")); + Assert.That(fields["p8"].HasStringValue, Is.True); + Assert.That(fields["p8"].StringValue, Is.EqualTo("2025-10-02T15:57:31.9990000Z")); + Assert.That(fields["p9"].HasStringValue, Is.True); + Assert.That(fields["p9"].StringValue, Is.EqualTo("5555990c-b259-4539-bd22-5a9293cf10ac")); + Assert.That(fields["p10"].HasNumberValue, Is.True); + Assert.That(fields["p10"].NumberValue, Is.EqualTo(3.14d)); + Assert.That(fields["p11"].HasNumberValue, Is.True); + Assert.That(fields["p11"].NumberValue, Is.EqualTo(3.14f)); + Assert.That(fields["p12"].HasNullValue, Is.True); + + Assert.That(fields["p13"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p13"].ListValue.Values.Count, Is.EqualTo(3)); + Assert.That(fields["p13"].ListValue.Values[0].HasBoolValue, Is.True); + Assert.That(fields["p13"].ListValue.Values[0].BoolValue, Is.True); + Assert.That(fields["p13"].ListValue.Values[1].HasBoolValue, Is.True); + Assert.That(fields["p13"].ListValue.Values[1].BoolValue, Is.False); + Assert.That(fields["p13"].ListValue.Values[2].HasNullValue, Is.True); + + Assert.That(fields["p14"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p14"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p14"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p14"].ListValue.Values[0].StringValue, Is.EqualTo(Convert.ToBase64String(new byte[]{1,2,3}))); + Assert.That(fields["p14"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p15"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p15"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p15"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p15"].ListValue.Values[0].StringValue, Is.EqualTo("2025-10-02")); + Assert.That(fields["p15"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p16"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p16"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p16"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p16"].ListValue.Values[0].StringValue, Is.EqualTo("P1DT2H3M4.005006S")); + Assert.That(fields["p16"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p17"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p17"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p17"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p17"].ListValue.Values[0].StringValue, Is.EqualTo("{\"key\": \"value\"}")); + Assert.That(fields["p17"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p18"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p18"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p18"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p18"].ListValue.Values[0].StringValue, Is.EqualTo("9.99")); + Assert.That(fields["p18"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p19"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p19"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p19"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p19"].ListValue.Values[0].StringValue, Is.EqualTo("test")); + Assert.That(fields["p19"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p20"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p20"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p20"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p20"].ListValue.Values[0].StringValue, Is.EqualTo("2025-10-02T15:57:31.9990000Z")); + Assert.That(fields["p20"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p21"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p21"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p21"].ListValue.Values[0].HasStringValue, Is.True); + Assert.That(fields["p21"].ListValue.Values[0].StringValue, Is.EqualTo("5555990c-b259-4539-bd22-5a9293cf10ac")); + Assert.That(fields["p21"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p22"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p22"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p22"].ListValue.Values[0].HasNumberValue, Is.True); + Assert.That(fields["p22"].ListValue.Values[0].NumberValue, Is.EqualTo(3.14d)); + Assert.That(fields["p22"].ListValue.Values[1].HasNullValue, Is.True); + + Assert.That(fields["p23"].KindCase, Is.EqualTo(Value.KindOneofCase.ListValue)); + Assert.That(fields["p23"].ListValue.Values.Count, Is.EqualTo(2)); + Assert.That(fields["p23"].ListValue.Values[0].HasNumberValue, Is.True); + Assert.That(fields["p23"].ListValue.Values[0].NumberValue, Is.EqualTo(3.14f)); + Assert.That(fields["p23"].ListValue.Values[1].HasNullValue, Is.True); + } + + private void AddParameter(DbCommand command, string name, object? value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + command.Parameters.Add(parameter); + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-tests/ConnectionTests.cs b/drivers/spanner-ado-net/spanner-ado-net-tests/ConnectionTests.cs new file mode 100644 index 00000000..b4a33abb --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-tests/ConnectionTests.cs @@ -0,0 +1,177 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Linq; +using Google.Cloud.Spanner.V1; +using Google.Cloud.SpannerLib; +using Google.Cloud.SpannerLib.MockServer; +using Grpc.Core; +using TypeCode = Google.Cloud.Spanner.V1.TypeCode; + +namespace Google.Cloud.Spanner.DataProvider.Tests; + +public class ConnectionTests : AbstractMockServerTests +{ + [Test] + public void TestOpenConnection() + { + var connection = new SpannerConnection { ConnectionString = ConnectionString }; + connection.Open(); + connection.Close(); + } + + [Test] + public void TestExecute() + { + var sql = "update all_types set col_float8=1 where col_bigint=1"; + Fixture.SpannerMock.AddOrUpdateStatementResult(sql, StatementResult.CreateUpdateCount(1)); + using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + connection.Open(); + + var command = connection.CreateCommand(); + command.CommandText = sql; + var updateCount = command.ExecuteNonQuery(); + Assert.That(updateCount, Is.EqualTo(1)); + } + + [Test] + public void TestQuery() + { + var sql = "select col_varchar from all_types where col_varchar is not null limit 10"; + Fixture.SpannerMock.AddOrUpdateStatementResult(sql, StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = TypeCode.String}, "col_varchar", "value1", "value2", "value3")); + using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + connection.Open(); + + using var command = connection.CreateCommand(); + command.CommandText = sql; + var rowCount = 0; + using (var reader = command.ExecuteReader()) + { + while (reader.Read()) + { + rowCount++; + Assert.That(reader.GetString(0), Is.EqualTo($"value{rowCount}")); + } + } + Assert.That(rowCount, Is.EqualTo(3)); + } + + [Test] + public void TestParameterizedQuery() + { + var sql = "select * from all_types where col_varchar=@p1"; + Fixture.SpannerMock.AddOrUpdateStatementResult(sql, StatementResult.CreateSelect1ResultSet()); + using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + connection.Open(); + + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.Parameters.Add("2de7b24590e00a58fa7358c9531301c5"); + using (var reader = command.ExecuteReader()) + { + for (int i = 0; i < reader.FieldCount; i++) + { + Assert.That(reader.GetFieldType(i), Is.Not.Null); + } + while (reader.Read()) + { + for (int i = 0; i < reader.FieldCount; i++) + { + Assert.That(reader.GetValue(i), Is.Not.Null); + } + } + } + var requests = Fixture.SpannerMock.Requests.OfType().ToList(); + Assert.That(requests, Has.Count.EqualTo(1)); + var request = requests.First(); + Assert.That(request.Params.Fields, Has.Count.EqualTo(1)); + } + + [Test] + public void TestTransaction() + { + var sql = "select * from all_types where col_varchar=$1"; + Fixture.SpannerMock.AddOrUpdateStatementResult(sql, StatementResult.CreateSelect1ResultSet()); + using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + connection.Open(); + + using var transaction = connection.BeginTransaction(); + using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = sql; + command.Parameters.Add("2de7b24590e00a58fa7358c9531301c5"); + using (var reader = command.ExecuteReader()) + { + for (int i = 0; i < reader.FieldCount; i++) + { + Assert.That(reader.GetFieldType(i), Is.Not.Null); + } + while (reader.Read()) + { + for (int i = 0; i < reader.FieldCount; i++) + { + Assert.That(reader.GetValue(i), Is.Not.Null); + } + } + } + transaction.Commit(); + } + + [Test] + public void TestDisableInternalRetries() + { + var sql = "update my_table set value=@p1 where id=@p2 and version=1"; + Fixture.SpannerMock.AddOrUpdateStatementResult(sql, StatementResult.CreateUpdateCount(1)); + using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString + ";retryAbortsInternally=false"; + connection.Open(); + + using var transaction = connection.BeginTransaction(); + using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = sql; + command.Parameters.Add("2de7b24590e00a58fa7358c9531301c5"); + command.Parameters.Add(1L); + command.ExecuteNonQuery(); + Fixture.SpannerMock.AddOrUpdateExecutionTime(nameof(Fixture.SpannerMock.Commit), ExecutionTime.CreateException(StatusCode.Aborted, "Transaction was aborted")); + Assert.Throws(transaction.Commit); + + var requests = Fixture.SpannerMock.Requests.OfType().ToList(); + Assert.That(requests.Count, Is.EqualTo(1)); + } + + [Test] + public void TestBatchDml() + { + var sql1 = "update all_types set col_float8=1 where col_bigint=1"; + var sql2 = "update all_types set col_float8=2 where col_bigint=2"; + Fixture.SpannerMock.AddOrUpdateStatementResult(sql1, StatementResult.CreateUpdateCount(2)); + Fixture.SpannerMock.AddOrUpdateStatementResult(sql2, StatementResult.CreateUpdateCount(3)); + using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + connection.Open(); + + using var command1 = connection.CreateCommand(); + command1.CommandText = sql1; + using var command2 = connection.CreateCommand(); + command2.CommandText = sql2; + var affected = connection.ExecuteBatchDml([command1, command2]); + Assert.That(affected, Is.EqualTo(new long[] { 2, 3 })); + } + +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-tests/README.md b/drivers/spanner-ado-net/spanner-ado-net-tests/README.md new file mode 100644 index 00000000..8e93c1e4 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-tests/README.md @@ -0,0 +1,5 @@ +# Spanner ADO.NET Data Provider Tests + +Tests for ADO.NET Data Provider for Spanner. + +__ALPHA: Not for production use__ diff --git a/drivers/spanner-ado-net/spanner-ado-net-tests/TransactionTests.cs b/drivers/spanner-ado-net/spanner-ado-net-tests/TransactionTests.cs new file mode 100644 index 00000000..3a0353d1 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-tests/TransactionTests.cs @@ -0,0 +1,182 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Google.Cloud.Spanner.V1; +using Google.Cloud.SpannerLib.MockServer; + +namespace Google.Cloud.Spanner.DataProvider.Tests; + +public class TransactionTests : AbstractMockServerTests +{ + [Test] + public async Task TestReadWriteTransaction() + { + const string sql = "update my_table set my_column=@value where id=@id"; + Fixture.SpannerMock.AddOrUpdateStatementResult(sql, StatementResult.CreateUpdateCount(1L)); + + await using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + await connection.OpenAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = sql; + var paramId = command.CreateParameter(); + paramId.ParameterName = "id"; + paramId.Value = 1; + command.Parameters.Add(paramId); + var paramValue = command.CreateParameter(); + paramValue.ParameterName = "value"; + paramValue.Value = "One"; + command.Parameters.Add(paramValue); + var updateCount = await command.ExecuteNonQueryAsync(); + await transaction.CommitAsync(); + + Assert.That(updateCount, Is.EqualTo(1)); + var requests = Fixture.SpannerMock.Requests.ToList(); + // The transaction should use inline-begin. + Assert.That(requests.OfType().Count(), Is.EqualTo(0)); + Assert.That(requests.OfType().Count(), Is.EqualTo(1)); + Assert.That(requests.OfType().Count(), Is.EqualTo(1)); + var executeRequest = requests.OfType().First(); + Assert.That(executeRequest.Transaction, Is.EqualTo(new TransactionSelector + { + Begin = new TransactionOptions + { + ReadWrite = new TransactionOptions.Types.ReadWrite(), + } + })); + } + + [Test] + public async Task TestReadOnlyTransaction() + { + const string sql = "select value from my_table where id=@id"; + Fixture.SpannerMock.AddOrUpdateStatementResult(sql, StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = V1.TypeCode.String}, "value", "One")); + + await using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + await connection.OpenAsync(); + await using var transaction = connection.BeginReadOnlyTransaction(); + await using var command = connection.CreateCommand(); + command.CommandText = sql; + var paramId = command.CreateParameter(); + paramId.ParameterName = "id"; + paramId.Value = 1; + command.Parameters.Add(paramId); + await using var reader = await command.ExecuteReaderAsync(); + Assert.That(await reader.ReadAsync()); + Assert.That(reader.FieldCount, Is.EqualTo(1)); + Assert.That(reader.GetValue(0), Is.EqualTo("One")); + Assert.That(await reader.ReadAsync(), Is.False); + + // We must commit the transaction in order to end it. + await transaction.CommitAsync(); + + var requests = Fixture.SpannerMock.Requests.ToList(); + // The transaction should use inline-begin. + Assert.That(requests.OfType().Count(), Is.EqualTo(0)); + Assert.That(requests.OfType().Count(), Is.EqualTo(1)); + // Committing a read-only transaction is a no-op on Spanner. + Assert.That(requests.OfType().Count(), Is.EqualTo(0)); + var executeRequest = requests.OfType().First(); + Assert.That(executeRequest.Transaction, Is.EqualTo(new TransactionSelector + { + Begin = new TransactionOptions + { + ReadOnly = new TransactionOptions.Types.ReadOnly + { + Strong = true, + ReturnReadTimestamp = true, + }, + } + })); + } + + [Ignore("Needs a fix in SpannerLib")] + [Test] + public async Task TestTransactionTag() + { + const string select = "select value from my_table where id=@id"; + Fixture.SpannerMock.AddOrUpdateStatementResult(select, StatementResult.CreateSingleColumnResultSet(new V1.Type{Code = V1.TypeCode.String}, "value", "one")); + const string update = "update my_table set my_column=@value where id=@id"; + Fixture.SpannerMock.AddOrUpdateStatementResult(update, StatementResult.CreateUpdateCount(1L)); + + await using var connection = new SpannerConnection(); + connection.ConnectionString = ConnectionString; + await connection.OpenAsync(); + await using var setTagCommand = connection.CreateCommand(); + setTagCommand.CommandText = "set transaction_tag='test_tag'"; + await setTagCommand.ExecuteNonQueryAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = select; + var selectParamId = command.CreateParameter(); + selectParamId.ParameterName = "id"; + selectParamId.Value = 1; + command.Parameters.Add(selectParamId); + await using var reader = await command.ExecuteReaderAsync(); + Assert.That(await reader.ReadAsync()); + Assert.That(reader.FieldCount, Is.EqualTo(1)); + Assert.That(reader.GetValue(0), Is.EqualTo("one")); + Assert.That(await reader.ReadAsync(), Is.False); + + await using var updateCommand = connection.CreateCommand(); + updateCommand.CommandText = update; + var paramId = updateCommand.CreateParameter(); + paramId.ParameterName = "id"; + paramId.Value = 1; + updateCommand.Parameters.Add(paramId); + var paramValue = updateCommand.CreateParameter(); + paramValue.ParameterName = "value"; + paramValue.Value = "One"; + updateCommand.Parameters.Add(paramValue); + var updateCount = await updateCommand.ExecuteNonQueryAsync(); + await transaction.CommitAsync(); + + Assert.That(updateCount, Is.EqualTo(1)); + var requests = Fixture.SpannerMock.Requests.ToList(); + // The transaction should use inline-begin. + Assert.That(requests.OfType().Count(), Is.EqualTo(0)); + Assert.That(requests.OfType().Count(), Is.EqualTo(2)); + Assert.That(requests.OfType().Count(), Is.EqualTo(1)); + var selectRequest = requests.OfType().First(); + Assert.That(selectRequest.Transaction, Is.EqualTo(new TransactionSelector + { + Begin = new TransactionOptions + { + ReadWrite = new TransactionOptions.Types.ReadWrite(), + } + })); + Assert.That(selectRequest.RequestOptions.TransactionTag, Is.EqualTo("test_tag")); + var updateRequest = requests.OfType().Single(request => request.Sql == update); + Assert.That(updateRequest.RequestOptions.TransactionTag, Is.EqualTo("test_tag")); + var commitRequest = requests.OfType().Single(); + Assert.That(commitRequest.RequestOptions.TransactionTag, Is.EqualTo("test_tag")); + + // The next transaction should not use the tag. + await using var tx2 = await connection.BeginTransactionAsync(); + await using var command2 = connection.CreateCommand(); + command2.CommandText = update; + command2.Parameters.Add(paramId); + command2.Parameters.Add(paramValue); + await command2.ExecuteNonQueryAsync(); + await tx2.CommitAsync(); + + var lastRequest = requests.OfType().Last(request => request.Sql == update); + Assert.That(lastRequest.RequestOptions.TransactionTag, Is.Null); + var lastCommitRequest = requests.OfType().Last(); + Assert.That(lastCommitRequest.RequestOptions.TransactionTag, Is.Null); + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net-tests/appsettings.json b/drivers/spanner-ado-net/spanner-ado-net-tests/appsettings.json new file mode 100644 index 00000000..2a8537d3 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-tests/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Microsoft": "Warning" + } + } +} diff --git a/drivers/spanner-ado-net/spanner-ado-net-tests/spanner-ado-net-tests.csproj b/drivers/spanner-ado-net/spanner-ado-net-tests/spanner-ado-net-tests.csproj new file mode 100644 index 00000000..051168cf --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net-tests/spanner-ado-net-tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + Google.Cloud.Spanner.DataProvider.Tests + enable + enable + + false + true + Google.Cloud.Spanner.DataProvider.Tests + default + + + + + + + + + + + + + + + + + + + + diff --git a/drivers/spanner-ado-net/spanner-ado-net.sln b/drivers/spanner-ado-net/spanner-ado-net.sln new file mode 100644 index 00000000..bfaca2ca --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net.sln @@ -0,0 +1,40 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "spanner-ado-net", "spanner-ado-net\spanner-ado-net.csproj", "{C01E227F-E396-45E7-A82F-478EFA9AC0A6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "spanner-ado-net-tests", "spanner-ado-net-tests\spanner-ado-net-tests.csproj", "{56052199-927F-46F5-8D0F-4826360E70B8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "spanner-ado-net-specification-tests", "spanner-ado-net-specification-tests\spanner-ado-net-specification-tests.csproj", "{97D93DB7-CEB6-4C21-B4C2-A5A98D3FD59C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "spanner-ado-net-samples", "spanner-ado-net-samples\spanner-ado-net-samples.csproj", "{537A257C-0228-418F-9DD5-A46324E591AE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "spanner-ado-net-benchmarks", "spanner-ado-net-benchmarks\spanner-ado-net-benchmarks.csproj", "{2C70D969-A8AA-440B-81D8-532C327F237E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C01E227F-E396-45E7-A82F-478EFA9AC0A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C01E227F-E396-45E7-A82F-478EFA9AC0A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C01E227F-E396-45E7-A82F-478EFA9AC0A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C01E227F-E396-45E7-A82F-478EFA9AC0A6}.Release|Any CPU.Build.0 = Release|Any CPU + {56052199-927F-46F5-8D0F-4826360E70B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {56052199-927F-46F5-8D0F-4826360E70B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {56052199-927F-46F5-8D0F-4826360E70B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {56052199-927F-46F5-8D0F-4826360E70B8}.Release|Any CPU.Build.0 = Release|Any CPU + {97D93DB7-CEB6-4C21-B4C2-A5A98D3FD59C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97D93DB7-CEB6-4C21-B4C2-A5A98D3FD59C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97D93DB7-CEB6-4C21-B4C2-A5A98D3FD59C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97D93DB7-CEB6-4C21-B4C2-A5A98D3FD59C}.Release|Any CPU.Build.0 = Release|Any CPU + {537A257C-0228-418F-9DD5-A46324E591AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {537A257C-0228-418F-9DD5-A46324E591AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {537A257C-0228-418F-9DD5-A46324E591AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {537A257C-0228-418F-9DD5-A46324E591AE}.Release|Any CPU.Build.0 = Release|Any CPU + {2C70D969-A8AA-440B-81D8-532C327F237E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C70D969-A8AA-440B-81D8-532C327F237E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C70D969-A8AA-440B-81D8-532C327F237E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C70D969-A8AA-440B-81D8-532C327F237E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/drivers/spanner-ado-net/spanner-ado-net/AssemblyInfo.cs b/drivers/spanner-ado-net/spanner-ado-net/AssemblyInfo.cs new file mode 100644 index 00000000..9c773122 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; +[assembly:InternalsVisibleTo("Google.Cloud.Spanner.DataProvider.Tests")] +[assembly:InternalsVisibleTo("Google.Cloud.Spanner.DataProvider.SpecificationTests")] diff --git a/drivers/spanner-ado-net/spanner-ado-net/README.md b/drivers/spanner-ado-net/spanner-ado-net/README.md new file mode 100644 index 00000000..fbcfda13 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/README.md @@ -0,0 +1,5 @@ +# Spanner ADO.NET Data Provider + +ADO.NET Data Provider for Spanner. + +__ALPHA: Not for production use__ diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerBatch.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerBatch.cs new file mode 100644 index 00000000..30ca2cab --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerBatch.cs @@ -0,0 +1,129 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Google.Api.Gax; +using Google.Cloud.Spanner.V1; + +namespace Google.Cloud.Spanner.DataProvider; + +/// +/// SpannerBatch is the Spanner-specific implementation of DbBatch. SpannerBatch supports batches of DML or DDL +/// statements. Note that all statements in a batch must be of the same type. Batches of queries or DML statements with +/// a THEN RETURN / RETURNING clause are not supported. +/// +public class SpannerBatch : DbBatch +{ + private SpannerConnection SpannerConnection => (SpannerConnection)Connection!; + protected override SpannerBatchCommandCollection DbBatchCommands { get; } = new(); + public override int Timeout { get; set; } + protected override DbConnection? DbConnection { get; set; } + protected override DbTransaction? DbTransaction { get; set; } + + public SpannerBatch() + {} + + internal SpannerBatch(SpannerConnection connection) + { + Connection = GaxPreconditions.CheckNotNull(connection, nameof(connection)); + } + + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) + { + throw new System.NotImplementedException(); + } + + protected override Task ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken) + { + throw new System.NotImplementedException(); + } + + private List CreateStatements() + { + var statements = new List(DbBatchCommands.Count); + foreach (var command in DbBatchCommands) + { + var spannerParams = ((SpannerParameterCollection)command.Parameters).CreateSpannerParams(); + var queryParams = spannerParams.Item1; + var paramTypes = spannerParams.Item2; + var batchStatement = new ExecuteBatchDmlRequest.Types.Statement + { + Sql = command.CommandText, + Params = queryParams, + }; + batchStatement.ParamTypes.Add(paramTypes); + statements.Add(batchStatement); + } + return statements; + } + + public override int ExecuteNonQuery() + { + if (DbBatchCommands.Count == 0) + { + return 0; + } + var statements = CreateStatements(); + var results = SpannerConnection.LibConnection!.ExecuteBatch(statements); + DbBatchCommands.SetAffected(results); + return (int) results.Sum(); + } + + public override async Task ExecuteNonQueryAsync(CancellationToken cancellationToken = default) + { + if (DbBatchCommands.Count == 0) + { + return 0; + } + var statements = CreateStatements(); + var results = await SpannerConnection.LibConnection!.ExecuteBatchAsync(statements); + DbBatchCommands.SetAffected(results); + return (int) results.Sum(); + } + + public override object? ExecuteScalar() + { + throw new System.NotImplementedException(); + } + + public override Task ExecuteScalarAsync(CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + + public override void Prepare() + { + throw new System.NotImplementedException(); + } + + public override Task PrepareAsync(CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + + public override void Cancel() + { + throw new System.NotImplementedException(); + } + + protected override DbBatchCommand CreateDbBatchCommand() + { + return new SpannerBatchCommand(); + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerBatchCommand.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerBatchCommand.cs new file mode 100644 index 00000000..4e93a7cd --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerBatchCommand.cs @@ -0,0 +1,34 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Data; +using System.Data.Common; + +namespace Google.Cloud.Spanner.DataProvider; + +public class SpannerBatchCommand : DbBatchCommand +{ + public override string CommandText { get; set; } = ""; + public override CommandType CommandType { get; set; } + + internal int InternalRecordsAffected; + public override int RecordsAffected => InternalRecordsAffected; + protected override DbParameterCollection DbParameterCollection { get; } = new SpannerParameterCollection(); + public override bool CanCreateParameter => true; + + public override DbParameter CreateParameter() + { + return new SpannerParameter(); + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerBatchCommandCollection.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerBatchCommandCollection.cs new file mode 100644 index 00000000..84f91f9e --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerBatchCommandCollection.cs @@ -0,0 +1,95 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using System.Data.Common; +using Google.Api.Gax; + +namespace Google.Cloud.Spanner.DataProvider; + +public class SpannerBatchCommandCollection : DbBatchCommandCollection +{ + private readonly List _commands = new (); + public override int Count => _commands.Count; + public override bool IsReadOnly => false; + + internal void SetAffected(long[] affected) + { + for (var i = 0; i < _commands.Count; i++) + { + _commands[i].InternalRecordsAffected = (int) affected[i]; + } + } + + public override IEnumerator GetEnumerator() + { + return _commands.GetEnumerator(); + } + + public override void Add(DbBatchCommand item) + { + GaxPreconditions.CheckNotNull(item, nameof(item)); + GaxPreconditions.CheckArgument(item is SpannerBatchCommand, nameof(item), "Item must be a SpannerBatchCommand"); + _commands.Add((SpannerBatchCommand)item); + } + + public override void Clear() + { + _commands.Clear(); + } + + public override bool Contains(DbBatchCommand item) + { + GaxPreconditions.CheckArgument(item is SpannerBatchCommand, nameof(item), "Item must be a SpannerBatchCommand"); + return _commands.Contains((SpannerBatchCommand)item); + } + + public override void CopyTo(DbBatchCommand[] array, int arrayIndex) + { + throw new System.NotImplementedException(); + } + + public override bool Remove(DbBatchCommand item) + { + GaxPreconditions.CheckArgument(item is SpannerBatchCommand, nameof(item), "Item must be a SpannerBatchCommand"); + return _commands.Remove((SpannerBatchCommand)item); + } + + public override int IndexOf(DbBatchCommand item) + { + GaxPreconditions.CheckArgument(item is SpannerBatchCommand, nameof(item), "Item must be a SpannerBatchCommand"); + return _commands.IndexOf((SpannerBatchCommand)item); + } + + public override void Insert(int index, DbBatchCommand item) + { + GaxPreconditions.CheckArgument(item is SpannerBatchCommand, nameof(item), "Item must be a SpannerBatchCommand"); + _commands.Insert(index, (SpannerBatchCommand)item); + } + + public override void RemoveAt(int index) + { + _commands.RemoveAt(index); + } + + protected override SpannerBatchCommand GetBatchCommand(int index) + { + return _commands[index]; + } + + protected override void SetBatchCommand(int index, DbBatchCommand batchCommand) + { + _commands[index] = (SpannerBatchCommand)batchCommand; + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerCommand.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerCommand.cs new file mode 100644 index 00000000..912c11ac --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerCommand.cs @@ -0,0 +1,260 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Google.Api.Gax; +using Google.Cloud.Spanner.V1; +using Google.Cloud.SpannerLib; +using Google.Protobuf.WellKnownTypes; +using static Google.Cloud.Spanner.DataProvider.SpannerDbException; + +namespace Google.Cloud.Spanner.DataProvider; + +public class SpannerCommand : DbCommand +{ + private SpannerConnection SpannerConnection => (SpannerConnection)Connection!; + + private string _commandText = ""; + [AllowNull] public override string CommandText { get => _commandText; set => _commandText = value ?? ""; } + + public override int CommandTimeout { get; set; } + public override CommandType CommandType { get; set; } = CommandType.Text; + public override UpdateRowSource UpdatedRowSource { get; set; } + protected override DbConnection? DbConnection { get; set; } + protected override DbParameterCollection DbParameterCollection { get; } = new SpannerParameterCollection(); + protected override DbTransaction? DbTransaction { get; set; } + public override bool DesignTimeVisible { get; set; } + + private bool HasTransaction => DbTransaction is SpannerTransaction; + private readonly Mutation? _mutation; + + public TransactionOptions.Types.ReadOnly? SingleUseReadOnlyTransactionOptions { get; set; } + public RequestOptions? RequestOptions { get; set; } + + public SpannerCommand() {} + + internal SpannerCommand(SpannerConnection connection) + { + Connection = GaxPreconditions.CheckNotNull(connection, nameof(connection)); + } + + internal SpannerCommand(SpannerConnection connection, Mutation mutation) + { + Connection = GaxPreconditions.CheckNotNull(connection, nameof(connection)); + _mutation = mutation; + } + + public override void Cancel() + { + // TODO: Implement in Spanner lib + } + + internal ExecuteSqlRequest BuildStatement(ExecuteSqlRequest.Types.QueryMode mode = ExecuteSqlRequest.Types.QueryMode.Normal) + { + GaxPreconditions.CheckState(!(HasTransaction && SingleUseReadOnlyTransactionOptions != null), + "Cannot set both a transaction and single-use read-only options"); + var spannerParams = ((SpannerParameterCollection)DbParameterCollection).CreateSpannerParams(); + var queryParams = spannerParams.Item1; + var paramTypes = spannerParams.Item2; + var statement = new ExecuteSqlRequest + { + Sql = CommandText, + Params = queryParams, + RequestOptions = RequestOptions, + QueryMode = mode, + }; + statement.ParamTypes.Add(paramTypes); + if (SingleUseReadOnlyTransactionOptions != null) + { + statement.Transaction = new TransactionSelector + { + SingleUse = new TransactionOptions + { + ReadOnly = SingleUseReadOnlyTransactionOptions, + }, + }; + } + + return statement; + } + + private Mutation BuildMutation() + { + GaxPreconditions.CheckNotNull(_mutation, nameof(_mutation)); + GaxPreconditions.CheckNotNull(SpannerConnection, nameof(SpannerConnection)); + GaxPreconditions.CheckState(!(HasTransaction && SingleUseReadOnlyTransactionOptions != null), + "Cannot set both a transaction and single-use read-only options"); + + var mutation = _mutation!.Clone(); + Mutation.Types.Write? write = null; + Mutation.Types.Delete? delete = mutation.OperationCase == Mutation.OperationOneofCase.Delete + ? mutation.Delete + : null; + switch (mutation.OperationCase) + { + case Mutation.OperationOneofCase.Insert: + write = mutation.Insert; + break; + case Mutation.OperationOneofCase.Update: + write = mutation.Update; + break; + case Mutation.OperationOneofCase.InsertOrUpdate: + write = mutation.InsertOrUpdate; + break; + case Mutation.OperationOneofCase.Replace: + write = mutation.Replace; + break; + } + + var values = new ListValue(); + for (var index = 0; index < DbParameterCollection.Count; index++) + { + var param = DbParameterCollection[index]; + if (param is SpannerParameter spannerParameter) + { + if (write != null) + { + var name = param.ParameterName; + if (name.StartsWith("@")) + { + name = name[1..]; + } + + write.Columns.Add(name); + } + + values.Values.Add(spannerParameter.ConvertToProto()); + } + else + { + throw new ArgumentException("parameter is not a SpannerParameter: " + param.ParameterName); + } + } + + write?.Values.Add(values); + if (delete != null) + { + delete.KeySet = new KeySet(); + delete.KeySet.Keys.Add(values); + } + + return mutation; + } + + private void ExecuteMutation() + { + GaxPreconditions.CheckState(_mutation != null, "Cannot execute mutation"); + var mutations = new BatchWriteRequest.Types.MutationGroup + { + Mutations = { BuildMutation() } + }; + SpannerConnection.LibConnection!.WriteMutations(mutations); + } + + private Rows Execute(ExecuteSqlRequest.Types.QueryMode mode = ExecuteSqlRequest.Types.QueryMode.Normal) + { + CheckCommandStateForExecution(); + return TranslateException(() => SpannerConnection.LibConnection!.Execute(BuildStatement(mode))); + } + + private Task ExecuteAsync(CancellationToken cancellationToken) + { + CheckCommandStateForExecution(); + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + return TranslateException(() => SpannerConnection.LibConnection!.ExecuteAsync(BuildStatement())); + } + + private void CheckCommandStateForExecution() + { + GaxPreconditions.CheckState(!string.IsNullOrEmpty(_commandText), "Cannot execute empty command"); + GaxPreconditions.CheckNotNull(SpannerConnection, nameof(SpannerConnection)); + GaxPreconditions.CheckState(Transaction == null || Transaction.Connection == SpannerConnection, + "The transaction that has been set for this command is from a different connection"); + } + + public override int ExecuteNonQuery() + { + if (_mutation != null) + { + ExecuteMutation(); + return 1; + } + + var rows = Execute(); + try + { + return (int)rows.UpdateCount; + } + finally + { + rows.Close(); + } + } + + public override object? ExecuteScalar() + { + GaxPreconditions.CheckState(_mutation == null, "Cannot execute mutations with ExecuteScalar()"); + var rows = Execute(); + using var reader = new SpannerDataReader(SpannerConnection, rows, CommandBehavior.Default); + if (reader.Read()) + { + if (reader.FieldCount > 0) + { + return reader.GetValue(0); + } + } + + return null; + } + + public override void Prepare() + { + Execute(ExecuteSqlRequest.Types.QueryMode.Plan); + } + + protected override DbParameter CreateDbParameter() + { + return new SpannerParameter(); + } + + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) + { + GaxPreconditions.CheckState(_mutation == null, "Cannot execute mutations with ExecuteDbDataReader()"); + var rows = Execute(); + return new SpannerDataReader(SpannerConnection, rows, behavior); + } + + protected override async Task ExecuteDbDataReaderAsync(CommandBehavior behavior, + CancellationToken cancellationToken) + { + GaxPreconditions.CheckState(_mutation == null, "Cannot execute mutations with ExecuteDbDataReader()"); + try + { + var rows = await ExecuteAsync(cancellationToken); + return new SpannerDataReader(SpannerConnection, rows, behavior); + } + catch (SpannerException exception) + { + throw new SpannerDbException(exception); + } + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerCommandBuilder.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerCommandBuilder.cs new file mode 100644 index 00000000..c1727c0d --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerCommandBuilder.cs @@ -0,0 +1,46 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Data; +using System.Data.Common; + +namespace Google.Cloud.Spanner.DataProvider; + +public class SpannerCommandBuilder : DbCommandBuilder +{ + protected override void ApplyParameterInfo(DbParameter parameter, DataRow row, StatementType statementType, bool whereClause) + { + throw new System.NotImplementedException(); + } + + protected override string GetParameterName(int parameterOrdinal) + { + throw new System.NotImplementedException(); + } + + protected override string GetParameterName(string parameterName) + { + throw new System.NotImplementedException(); + } + + protected override string GetParameterPlaceholder(int parameterOrdinal) + { + throw new System.NotImplementedException(); + } + + protected override void SetRowUpdatingHandler(DbDataAdapter adapter) + { + throw new System.NotImplementedException(); + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerConnection.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerConnection.cs new file mode 100644 index 00000000..4b128772 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerConnection.cs @@ -0,0 +1,290 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Google.Cloud.Spanner.Common.V1; +using Google.Cloud.Spanner.V1; +using Google.Cloud.SpannerLib; + +namespace Google.Cloud.Spanner.DataProvider; + +public class SpannerConnection : DbConnection +{ + public bool UseSharedLibrary { get; set; } + + private string _connectionString = string.Empty; + + [AllowNull] + public override string ConnectionString { + get => _connectionString; + set + { + AssertClosed(); + if (!IsValidConnectionString(value)) + { + throw new ArgumentException($"Invalid connection string: {value}"); + } + _connectionString = value ?? string.Empty; + } + } + + public override string Database + { + get + { + // TODO: Move this to SpannerLib. + if (String.IsNullOrWhiteSpace(ConnectionString)) + { + return ""; + } + var startIndex = ConnectionString.IndexOf("projects/", StringComparison.Ordinal); + if (startIndex == -1) + { + throw new ArgumentException($"Invalid database name in connection string: {ConnectionString}"); + } + + var endIndex = ConnectionString.IndexOf('?'); + if (endIndex == -1) + { + endIndex = ConnectionString.IndexOf(';'); + } + if (endIndex == -1) + { + endIndex = ConnectionString.Length; + } + var name = ConnectionString.Substring(startIndex, endIndex); + if (DatabaseName.TryParse(name, false, out var result)) + { + return result.DatabaseId; + } + throw new ArgumentException($"Invalid database name in connection string: {ConnectionString}"); + } + } + + private ConnectionState InternalState + { + get => _state; + set + { + var originalState = _state; + _state = value; + OnStateChange(new StateChangeEventArgs(originalState, _state)); + } + } + + public override ConnectionState State => InternalState; + protected override DbProviderFactory DbProviderFactory => SpannerFactory.Instance; + + public override string DataSource { get; } = ""; + public override string ServerVersion + { + get + { + AssertOpen(); + return Assembly.GetAssembly(typeof(Connection))?.GetName().Version?.ToString() ?? ""; + } + } + + public override bool CanCreateBatch => true; + + private bool _disposed; + private ConnectionState _state = ConnectionState.Closed; + private SpannerPool? Pool { get; set; } + + private Connection? _libConnection; + + internal Connection? LibConnection + { + get + { + AssertOpen(); + return _libConnection; + } + } + + private SpannerTransaction? _transaction; + + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) + { + return BeginTransaction(new TransactionOptions + { + IsolationLevel = SpannerTransaction.TranslateIsolationLevel(isolationLevel), + }); + } + + public DbTransaction BeginReadOnlyTransaction() + { + return BeginTransaction(new TransactionOptions + { + ReadOnly = new TransactionOptions.Types.ReadOnly(), + }); + } + + public DbTransaction BeginTransaction(TransactionOptions transactionOptions) + { + EnsureOpen(); + if (_transaction != null) + { + throw new InvalidOperationException("This connection has a transaction."); + } + _transaction = new SpannerTransaction(this, transactionOptions); + return _transaction; + } + + internal void ClearTransaction() + { + _transaction = null; + } + + public override void ChangeDatabase(string databaseName) + { + throw new NotImplementedException(); + } + + public override void Close() + { + if (InternalState == ConnectionState.Closed) + { + return; + } + + InternalState = ConnectionState.Closed; + _libConnection?.Close(); + _libConnection = null; + } + + protected override void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + if (disposing) + { + Close(); + } + base.Dispose(disposing); + _disposed = true; + } + + public override void Open() + { + AssertClosed(); + if (ConnectionString == string.Empty) + { + throw new InvalidOperationException("Connection string is empty"); + } + + InternalState = ConnectionState.Connecting; + Pool = SpannerPool.GetOrCreate(ConnectionString); + _libConnection = Pool.CreateConnection(); + InternalState = ConnectionState.Open; + } + + private void EnsureOpen() + { + if (InternalState == ConnectionState.Closed) + { + Open(); + } + } + + private void AssertOpen() + { + if (InternalState != ConnectionState.Open) + { + throw new InvalidOperationException("Connection is not open"); + } + } + + private void AssertClosed() + { + if (InternalState != ConnectionState.Closed) + { + throw new InvalidOperationException("Connection is not closed"); + } + } + + private bool IsValidConnectionString(string? connectionString) + { + // TODO: Move to Spanner lib. + return string.IsNullOrEmpty(connectionString) || connectionString.Contains("projects/"); + } + + public CommitResponse? WriteMutations(BatchWriteRequest.Types.MutationGroup mutations) + { + EnsureOpen(); + return LibConnection!.WriteMutations(mutations); + } + + public Task WriteMutationsAsync(BatchWriteRequest.Types.MutationGroup mutations, CancellationToken cancellationToken = default) + { + EnsureOpen(); + return LibConnection!.WriteMutationsAsync(mutations, cancellationToken); + } + + protected override DbCommand CreateDbCommand() + { + return new SpannerCommand(this); + } + + protected override DbBatch CreateDbBatch() + { + return new SpannerBatch(this); + } + + public long[] ExecuteBatchDml(List commands) + { + EnsureOpen(); + var statements = new List(commands.Count); + foreach (var command in commands) + { + if (command is SpannerCommand spannerCommand) + { + var statement = spannerCommand.BuildStatement(); + var batchStatement = new ExecuteBatchDmlRequest.Types.Statement + { + Sql = statement.Sql, + Params = statement.Params, + }; + batchStatement.ParamTypes.Add(statement.ParamTypes); + statements.Add(batchStatement); + } + } + return LibConnection!.ExecuteBatch(statements); + } + + public DbCommand CreateInsertCommand(string table) + { + return new SpannerCommand(this, new Mutation { Insert = new Mutation.Types.Write { Table = table } }); + } + + public DbCommand CreateUpdateCommand(string table) + { + return new SpannerCommand(this, new Mutation { Update = new Mutation.Types.Write { Table = table } }); + } + + public DbCommand CreateDeleteCommand(string table) + { + return new SpannerCommand(this, new Mutation { Delete = new Mutation.Types.Delete { Table = table } }); + } + +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerConnectionStringBuilder.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerConnectionStringBuilder.cs new file mode 100644 index 00000000..acd93cce --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerConnectionStringBuilder.cs @@ -0,0 +1,22 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Data.Common; + +namespace Google.Cloud.Spanner.DataProvider; + +public class SpannerConnectionStringBuilder : DbConnectionStringBuilder +{ + +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerDataAdapter.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerDataAdapter.cs new file mode 100644 index 00000000..74dfceab --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerDataAdapter.cs @@ -0,0 +1,22 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Data.Common; + +namespace Google.Cloud.Spanner.DataProvider; + +public class SpannerDataAdapter : DbDataAdapter +{ + +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerDataReader.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerDataReader.cs new file mode 100644 index 00000000..465ebc8d --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerDataReader.cs @@ -0,0 +1,825 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using Google.Api.Gax; +using Google.Cloud.Spanner.V1; +using Google.Cloud.SpannerLib; +using Google.Protobuf.WellKnownTypes; +using TypeCode = Google.Cloud.Spanner.V1.TypeCode; + +namespace Google.Cloud.Spanner.DataProvider; + +public class SpannerDataReader : DbDataReader +{ + private readonly SpannerConnection _connection; + private readonly CommandBehavior _commandBehavior; + private bool IsSingleRow => _commandBehavior.HasFlag(CommandBehavior.SingleRow); + private Rows LibRows { get; } + private bool _closed; + private bool _hasReadData; + private bool _hasData; + + public override int FieldCount + { + get + { + CheckNotClosed(); + return LibRows.Metadata?.RowType.Fields.Count ?? 0; + } + } + + public override object this[int ordinal] => GetFieldValue(ordinal); + public override object this[string name] => this[GetOrdinal(name)]; + + public override int RecordsAffected + { + get + { + CheckNotClosed(); + return (int)LibRows.UpdateCount; + } + } + + public override bool HasRows + { + get + { + CheckNotClosed(); + if (LibRows.Metadata?.RowType.Fields.Count == 0) + { + return false; + } + if (_hasReadData) + { + return _hasData; + } + return CheckForRows(); + } + } + public override bool IsClosed => _closed; + public override int Depth => 0; + + private ListValue? _currentRow; + private ListValue? _tempRow; + + internal SpannerDataReader(SpannerConnection connection, Rows libRows, CommandBehavior behavior) + { + _connection = connection; + LibRows = libRows; + _commandBehavior = behavior; + } + + private void CheckNotClosed() + { + GaxPreconditions.CheckState(!_closed, "Reader has been closed"); + } + + public override void Close() + { + if (_closed) + { + return; + } + + _closed = true; + LibRows.Close(); + if (_commandBehavior.HasFlag(CommandBehavior.CloseConnection)) + { + _connection.Close(); + } + } + + public override bool Read() + { + if (!InternalRead()) + { + _hasReadData = true; + _currentRow = LibRows.Next(); + } + return _currentRow != null; + } + + public override async Task ReadAsync(CancellationToken cancellationToken) + { + if (!InternalRead()) + { + _hasReadData = true; + _currentRow = await LibRows.NextAsync(); + } + return _currentRow != null; + } + + private bool InternalRead() + { + CheckNotClosed(); + if (_tempRow != null) + { + _currentRow = _tempRow; + _tempRow = null; + _hasReadData = true; + return true; + } + if (IsSingleRow && _hasReadData) + { + _currentRow = null; + return true; + } + return false; + } + + private bool CheckForRows() + { + _tempRow = LibRows.Next(); + return _tempRow != null; + } + + public override DataTable? GetSchemaTable() + { + CheckNotClosed(); + var metadata = LibRows.Metadata; + if (metadata?.RowType == null || metadata.RowType.Fields.Count == 0) + { + return null; + } + var table = new DataTable("SchemaTable"); + + table.Columns.Add("ColumnName", typeof(string)); + table.Columns.Add("ColumnOrdinal", typeof(int)); + table.Columns.Add("ColumnSize", typeof(int)); + table.Columns.Add("NumericPrecision", typeof(int)); + table.Columns.Add("NumericScale", typeof(int)); + table.Columns.Add("IsUnique", typeof(bool)); + table.Columns.Add("IsKey", typeof(bool)); + table.Columns.Add("BaseServerName", typeof(string)); + table.Columns.Add("BaseCatalogName", typeof(string)); + table.Columns.Add("BaseColumnName", typeof(string)); + table.Columns.Add("BaseSchemaName", typeof(string)); + table.Columns.Add("BaseTableName", typeof(string)); + table.Columns.Add("DataType", typeof(System.Type)); + table.Columns.Add("AllowDBNull", typeof(bool)); + table.Columns.Add("ProviderType", typeof(int)); + table.Columns.Add("IsAliased", typeof(bool)); + table.Columns.Add("IsExpression", typeof(bool)); + table.Columns.Add("IsIdentity", typeof(bool)); + table.Columns.Add("IsAutoIncrement", typeof(bool)); + table.Columns.Add("IsRowVersion", typeof(bool)); + table.Columns.Add("IsHidden", typeof(bool)); + table.Columns.Add("IsLong", typeof(bool)); + table.Columns.Add("IsReadOnly", typeof(bool)); + table.Columns.Add("ProviderSpecificDataType", typeof(System.Type)); + table.Columns.Add("DataTypeName", typeof(string)); + + var ordinal = 0; + foreach (var column in metadata.RowType.Fields) + { + ordinal++; + var row = table.NewRow(); + row["ColumnName"] = column.Name; + row["ColumnOrdinal"] = ordinal; + row["ColumnSize"] = -1; + row["NumericPrecision"] = 0; + row["NumericScale"] = 0; + row["IsUnique"] = false; + row["IsKey"] = false; + row["BaseServerName"] = ""; + row["BaseCatalogName"] = ""; + row["BaseColumnName"] = ""; + row["BaseSchemaName"] = ""; + row["BaseTableName"] = ""; + row["DataType"] = TypeConversion.GetSystemType(column.Type); + row["AllowDBNull"] = true; + row["ProviderType"] = (int)column.Type.Code; + row["IsAliased"] = false; + row["IsExpression"] = false; + row["IsIdentity"] = false; + row["IsAutoIncrement"] = false; + row["IsRowVersion"] = false; + row["IsHidden"] = false; + row["IsLong"] = false; + row["IsReadOnly"] = false; + row["DataTypeName"] = column.Type.Code.ToString(); + + table.Rows.Add(row); + } + return table; + } + + public override string GetString(int ordinal) + { + var value = GetProtoValue(ordinal); + CheckNotNull(ordinal); + if (value.HasStringValue) + { + return value.StringValue; + } + if (value.HasNumberValue) + { + var type = GetSpannerType(ordinal); + if (type.Code == TypeCode.Float32) + { + return ((float) value.NumberValue).ToString(CultureInfo.InvariantCulture); + } + return value.NumberValue.ToString(CultureInfo.InvariantCulture); + } + if (value.HasBoolValue) + { + return value.BoolValue.ToString(); + } + throw new InvalidCastException("not a valid string value"); + } + + public override bool GetBoolean(int ordinal) + { + var value = GetProtoValue(ordinal); + CheckNotNull(ordinal); + if (value.HasStringValue) + { + try + { + return bool.Parse(value.StringValue); + } + catch (Exception exception) + { + throw new InvalidCastException(exception.Message, exception); + } + } + if (value.HasBoolValue) + { + return value.BoolValue; + } + throw new InvalidCastException("not a valid bool value"); + } + + public override byte GetByte(int ordinal) + { + CheckValidPosition(); + CheckNotNull(ordinal); + throw new InvalidCastException("not a valid byte value"); + } + + public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length) + { + CheckValidPosition(); + CheckValidOrdinal(ordinal); + GaxPreconditions.CheckState(LibRows.Metadata!.RowType.Fields[ordinal].Type.Code == TypeCode.Bytes, + "Spanner only supports conversion to byte arrays for columns of type BYTES."); + GaxPreconditions.CheckArgumentRange(bufferOffset, nameof(bufferOffset), 0, buffer?.Length ?? 0); + GaxPreconditions.CheckArgumentRange(length, nameof(length), 0, buffer?.Length ?? int.MaxValue); + if (buffer != null) + { + GaxPreconditions.CheckArgumentRange(bufferOffset + length, nameof(length), 0, buffer.Length); + } + + var bytes = IsDBNull(ordinal) ? null : GetFieldValue(ordinal); + if (buffer == null) + { + // Return the length of the value if `buffer` is null: + // https://docs.microsoft.com/en-us/dotnet/api/system.data.idatarecord.getbytes?view=netstandard-2.1#remarks + return bytes?.Length ?? 0; + } + + var copyLength = Math.Min(length, (bytes?.Length ?? 0) - (int)dataOffset); + if (copyLength < 0) + { + // Read nothing and just return. + return 0; + } + + if (bytes != null) + { + Array.Copy(bytes, (int)dataOffset, buffer, bufferOffset, copyLength); + } + + return copyLength; + } + + public override char GetChar(int ordinal) + { + var value = GetProtoValue(ordinal); + CheckNotNull(ordinal); + var type = GetSpannerType(ordinal); + if (type.Code != TypeCode.String) + { + throw new InvalidCastException("not a valid char value"); + } + if (value.HasStringValue) + { + if (value.StringValue.Length == 1) + { + return value.StringValue[0]; + } + } + throw new InvalidCastException("not a valid char value"); + } + + public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length) + { + var value = GetProtoValue(ordinal); + if (value.HasNullValue) + { + return 0; + } + if (!value.HasStringValue) + { + throw new DataException("not a valid type for getting as chars"); + } + if (buffer == null) + { + // Return the length of the value if `buffer` is null: + // https://docs.microsoft.com/en-us/dotnet/api/system.data.idatarecord.getbytes?view=netstandard-2.1#remarks + return value.StringValue.ToCharArray().Length; + } + GaxPreconditions.CheckArgumentRange(bufferOffset, nameof(bufferOffset), 0, buffer.Length); + GaxPreconditions.CheckArgumentRange(length, nameof(length), 0, buffer.Length - bufferOffset); + + var intDataOffset = (int)dataOffset; + var sourceLength = Math.Min(length, value.StringValue.Length - intDataOffset); + var destLength = Math.Min(length, buffer.Length - bufferOffset); + destLength = Math.Min(destLength, sourceLength); + + if (destLength <= 0) + { + return 0; + } + if (bufferOffset + destLength > buffer.Length) + { + return 0; + } + + // TODO: Optimize + var chars = value.StringValue.ToCharArray(intDataOffset, sourceLength); + if (intDataOffset >= chars.Length) + { + return 0; + } + + Array.Copy(chars, 0, buffer, bufferOffset, destLength); + + return destLength; + } + + public override string GetDataTypeName(int ordinal) + { + CheckValidOrdinal(ordinal); + return GetTypeName(LibRows.Metadata!.RowType.Fields[ordinal].Type); + } + + private static string GetTypeName(Google.Cloud.Spanner.V1.Type type) + { + if (type.Code == TypeCode.Array) + { + return type.Code.GetOriginalName() + "<" + type.ArrayElementType.Code.GetOriginalName() + ">"; + } + return type.Code.GetOriginalName(); + } + + public override DateTime GetDateTime(int ordinal) + { + var value = GetProtoValue(ordinal); + CheckNotNull(ordinal); + var type = GetSpannerType(ordinal); + if (type.Code == TypeCode.Date) + { + var date = DateOnly.Parse(value.StringValue); + return date.ToDateTime(TimeOnly.MinValue); + } + if (value.HasStringValue) + { + try + { + return XmlConvert.ToDateTime(value.StringValue, XmlDateTimeSerializationMode.Utc); + } + catch (Exception exception) + { + throw new InvalidCastException(exception.Message, exception); + } + } + throw new InvalidCastException("not a valid DateTime value"); + } + + public override decimal GetDecimal(int ordinal) + { + var value = GetProtoValue(ordinal); + CheckNotNull(ordinal); + if (value.HasStringValue) + { + try + { + return decimal.Parse(value.StringValue, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign | NumberStyles.AllowExponent, CultureInfo.InvariantCulture); + } + catch (Exception exception) + { + throw new InvalidCastException(exception.Message, exception); + } + } + throw new InvalidCastException("not a valid decimal value"); + } + + public override double GetDouble(int ordinal) + { + var value = GetProtoValue(ordinal); + CheckNotNull(ordinal); + if (value.HasStringValue) + { + try + { + return double.Parse(value.StringValue, + NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign | NumberStyles.AllowExponent, + CultureInfo.InvariantCulture); + } + catch (Exception exception) + { + throw new InvalidCastException(exception.Message, exception); + } + } + if (value.HasNumberValue) + { + return value.NumberValue; + } + throw new InvalidCastException("not a valid double value"); + } + + public override System.Type GetFieldType(int ordinal) + { + CheckValidOrdinal(ordinal); + return GetClrType(LibRows.Metadata!.RowType.Fields[ordinal].Type); + } + + private static System.Type GetClrType(Google.Cloud.Spanner.V1.Type type) + { + return type.Code switch + { + TypeCode.Array => typeof(List<>).MakeGenericType(GetClrType(type.ArrayElementType)), + TypeCode.Bool => typeof(bool), + TypeCode.Bytes => typeof(byte[]), + TypeCode.Date => typeof(DateOnly), + TypeCode.Enum => typeof(int), + TypeCode.Float32 => typeof(float), + TypeCode.Float64 => typeof(double), + TypeCode.Int64 => typeof(long), + TypeCode.Interval => typeof(TimeSpan), + TypeCode.Json => typeof(string), + TypeCode.Numeric => typeof(decimal), + TypeCode.Proto => typeof(byte[]), + TypeCode.String => typeof(string), + TypeCode.Timestamp => typeof(DateTime), + TypeCode.Uuid => typeof(Guid), + _ => typeof(Value) + }; + } + + public override float GetFloat(int ordinal) + { + var value = GetProtoValue(ordinal); + CheckNotNull(ordinal); + if (value.HasStringValue) + { + try + { + return float.Parse(value.StringValue, + NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign | NumberStyles.AllowExponent, + CultureInfo.InvariantCulture); + } + catch (Exception exception) + { + throw new InvalidCastException(exception.Message, exception); + } + } + var type = GetSpannerType(ordinal); + if (type.Code == TypeCode.Float32) + { + return (float)value.NumberValue; + } + throw new InvalidCastException("not a valid float value"); + } + + public override Guid GetGuid(int ordinal) + { + var value = GetProtoValue(ordinal); + CheckNotNull(ordinal); + if (value.HasStringValue) + { + try + { + return Guid.Parse(value.StringValue); + } + catch (Exception exception) + { + throw new InvalidCastException(exception.Message, exception); + } + } + throw new InvalidCastException("not a valid Guid value"); + } + + public override short GetInt16(int ordinal) + { + var value = GetProtoValue(ordinal); + CheckNotNull(ordinal); + if (value.HasStringValue) + { + try + { + return short.Parse(value.StringValue, + NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign | NumberStyles.AllowExponent, + CultureInfo.InvariantCulture); + } + catch (OverflowException) + { + throw; + } + catch (Exception exception) + { + throw new InvalidCastException(exception.Message, exception); + } + } + if (value.HasNumberValue) + { + return (short)value.NumberValue; + } + throw new InvalidCastException("not a valid Int16 value"); + } + + public override int GetInt32(int ordinal) + { + var value = GetProtoValue(ordinal); + CheckNotNull(ordinal); + if (value.HasStringValue) + { + try + { + return int.Parse(value.StringValue, + NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign | NumberStyles.AllowExponent, + CultureInfo.InvariantCulture); + } + catch (OverflowException) + { + throw; + } + catch (Exception exception) + { + throw new InvalidCastException(exception.Message, exception); + } + } + if (value.HasNumberValue) + { + return (int)value.NumberValue; + } + throw new InvalidCastException("not a valid Int32 value"); + } + + public override long GetInt64(int ordinal) + { + var value = GetProtoValue(ordinal); + CheckNotNull(ordinal); + if (value.HasStringValue) + { + try + { + return long.Parse(value.StringValue, + NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign | NumberStyles.AllowExponent, + CultureInfo.InvariantCulture); + } + catch (Exception exception) + { + throw new InvalidCastException(exception.Message, exception); + } + } + if (value.HasNumberValue) + { + return (long)value.NumberValue; + } + throw new InvalidCastException("not a valid Int64 value"); + } + + public override string GetName(int ordinal) + { + CheckValidOrdinal(ordinal); + return LibRows.Metadata!.RowType.Fields[ordinal].Name; + } + + public override int GetOrdinal(string name) + { + CheckNotClosed(); + for (var i = 0; i < LibRows.Metadata?.RowType.Fields.Count; i++) + { + if (Equals(LibRows.Metadata?.RowType.Fields[i].Name, name)) + { + return i; + } + } + throw new IndexOutOfRangeException($"No column with name {name} found"); + } + + public override T GetFieldValue(int ordinal) + { + CheckNotClosed(); + CheckValidOrdinal(ordinal); + if (typeof(T) == typeof(Stream)) + { + CheckNotNull(ordinal); + return (T)(object)GetStream(ordinal); + } + if (typeof(T) == typeof(TextReader)) + { + CheckNotNull(ordinal); + return (T)(object)GetTextReader(ordinal); + } + if (typeof(T) == typeof(char)) + { + return (T)(object)GetChar(ordinal); + } + if (typeof(T) == typeof(DateTime)) + { + return (T)(object)GetDateTime(ordinal); + } + if (typeof(T) == typeof(double)) + { + return (T)(object)GetDouble(ordinal); + } + if (typeof(T) == typeof(float)) + { + return (T)(object)GetFloat(ordinal); + } + if (typeof(T) == typeof(Int16)) + { + return (T)(object)GetInt16(ordinal); + } + if (typeof(T) == typeof(int)) + { + return (T)(object)GetInt32(ordinal); + } + if (typeof(T) == typeof(long)) + { + return (T)(object)GetInt64(ordinal); + } + + return base.GetFieldValue(ordinal); + } + + public override object GetValue(int ordinal) + { + CheckValidOrdinal(ordinal); + CheckValidPosition(); + var type = LibRows.Metadata!.RowType.Fields[ordinal].Type; + var value = _currentRow!.Values[ordinal]; + return GetUnderlyingValue(type, value); + } + + private static object GetUnderlyingValue(Google.Cloud.Spanner.V1.Type type, Value value) + { + if (value.HasNullValue) + { + return DBNull.Value; + } + + switch (type.Code) + { + case TypeCode.Array: + var listType = typeof(List<>).MakeGenericType(GetClrType(type.ArrayElementType)); + var list = (IList)Activator.CreateInstance(listType); + foreach (var element in value.ListValue.Values) + { + list.Add(GetUnderlyingValue(type.ArrayElementType, element)); + } + return list; + case TypeCode.Bool: + return value.BoolValue; + case TypeCode.Bytes: + return Convert.FromBase64String(value.StringValue); + case TypeCode.Date: + return DateOnly.Parse(value.StringValue); + case TypeCode.Enum: + return long.Parse(value.StringValue); + case TypeCode.Float32: + return (float)value.NumberValue; + case TypeCode.Float64: + return value.NumberValue; + case TypeCode.Int64: + return long.Parse(value.StringValue); + case TypeCode.Interval: + return TimeSpan.Parse(value.StringValue); + case TypeCode.Json: + return value.StringValue; + case TypeCode.Numeric: + return decimal.Parse(value.StringValue, NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + case TypeCode.Proto: + return Convert.FromBase64String(value.StringValue); + case TypeCode.String: + return value.StringValue; + case TypeCode.Timestamp: + return XmlConvert.ToDateTime(value.StringValue, XmlDateTimeSerializationMode.Utc); + case TypeCode.Uuid: + return Guid.Parse(value.StringValue); + } + if (value.HasBoolValue) + { + return value.BoolValue; + } + if (value.HasStringValue) + { + return value.StringValue; + } + if (value.HasNumberValue) + { + return value.NumberValue; + } + return value; + } + + private Value GetProtoValue(int ordinal) + { + CheckValidOrdinal(ordinal); + CheckValidPosition(); + return _currentRow!.Values[ordinal]; + } + + private V1.Type GetSpannerType(int ordinal) + { + CheckValidOrdinal(ordinal); + return LibRows.Metadata?.RowType.Fields[ordinal].Type ?? throw new DataException("metadata not found"); + } + + public override int GetValues(object[] values) + { + CheckValidPosition(); + GaxPreconditions.CheckNotNull(values, nameof(values)); + + var count = Math.Min(FieldCount, values.Length); + for (var i = 0; i < count; i++) + { + values[i] = this[i]; + } + + return count; + } + + public override bool IsDBNull(int ordinal) + { + var value = GetProtoValue(ordinal); + return value.HasNullValue; + } + + public override bool NextResult() + { + CheckNotClosed(); + return false; + } + + public override IEnumerator GetEnumerator() + { + CheckNotClosed(); + return new DbEnumerator(this); + } + + private void CheckValidPosition() + { + CheckNotClosed(); + if (_currentRow == null) + { + throw new InvalidOperationException("DataReader is before the first row or after the last row"); + } + } + + private void CheckValidOrdinal(int ordinal) + { + CheckNotClosed(); + var metadata = LibRows.Metadata; + GaxPreconditions.CheckState(metadata != null && metadata.RowType.Fields.Count > 0, "This reader does not contain any rows"); + + // Check that the ordinal is within the range of the columns in the query. + if (ordinal < 0 || ordinal >= metadata!.RowType.Fields.Count) + { + throw new IndexOutOfRangeException("ordinal is out of range"); + } + } + + private void CheckNotNull(int ordinal) + { + if (_currentRow?.Values[ordinal]?.HasNullValue ?? false) + { + throw new InvalidCastException("Value is null"); + } + } + +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerDataSource.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerDataSource.cs new file mode 100644 index 00000000..162b7d0c --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerDataSource.cs @@ -0,0 +1,33 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Data.Common; + +namespace Google.Cloud.Spanner.DataProvider; + +public class SpannerDataSource : DbDataSource +{ + public override string ConnectionString { get; } + + public static SpannerDataSource Create(string connectionString) + { + throw new NotImplementedException(); + } + + protected override DbConnection CreateDbConnection() + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerDbException.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerDbException.cs new file mode 100644 index 00000000..eb03b77d --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerDbException.cs @@ -0,0 +1,42 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Data.Common; +using Google.Cloud.SpannerLib; + +namespace Google.Cloud.Spanner.DataProvider; + +public class SpannerDbException : DbException +{ + internal static T TranslateException(Func func) + { + try + { + return func(); + } + catch (SpannerException exception) + { + throw new SpannerDbException(exception); + } + } + + private SpannerException SpannerException { get; } + + internal SpannerDbException(SpannerException spannerException) : base(spannerException.Message, spannerException.Status.Code) + { + SpannerException = spannerException; + } + +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerFactory.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerFactory.cs new file mode 100644 index 00000000..6bfe1967 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerFactory.cs @@ -0,0 +1,94 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Data.Common; + +namespace Google.Cloud.Spanner.DataProvider; + +public class SpannerFactory : DbProviderFactory, IServiceProvider +{ + /// + /// Gets an instance of the . + /// This can be used to retrieve strongly typed data objects. + /// + public static readonly SpannerFactory Instance = new(); + + SpannerFactory() {} + + /// + /// Returns a strongly typed instance. + /// + public override DbCommand CreateCommand() => new SpannerCommand(); + + /// + /// Returns a strongly typed instance. + /// + public override DbConnection CreateConnection() => new SpannerConnection(); + + /// + /// Returns a strongly typed instance. + /// + public override DbParameter CreateParameter() => new SpannerParameter(); + + /// + /// Returns a strongly typed instance. + /// + public override DbConnectionStringBuilder CreateConnectionStringBuilder() => new SpannerConnectionStringBuilder(); + + /// + /// Returns a strongly typed instance. + /// + public override DbCommandBuilder CreateCommandBuilder() => new SpannerCommandBuilder(); + + /// + /// Returns a strongly typed instance. + /// + public override DbDataAdapter CreateDataAdapter() => new SpannerDataAdapter(); + + /// + /// Specifies whether the specific supports the class. + /// + public override bool CanCreateDataAdapter => true; + + /// + /// Specifies whether the specific supports the class. + /// + public override bool CanCreateCommandBuilder => true; + + /// + public override bool CanCreateBatch => true; + + /// + public override DbBatch CreateBatch() => new SpannerBatch(); + + /// + public override DbBatchCommand CreateBatchCommand() => new SpannerBatchCommand(); + + /// + public override DbDataSource CreateDataSource(string connectionString) + => SpannerDataSource.Create(connectionString); + + #region IServiceProvider Members + + /// + /// Gets the service object of the specified type. + /// + /// An object that specifies the type of service object to get. + /// A service object of type serviceType, or null if there is no service object of type serviceType. + public object? GetService(System.Type serviceType) => null; + + #endregion + +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerParameter.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerParameter.cs new file mode 100644 index 00000000..f1e7e2d0 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerParameter.cs @@ -0,0 +1,186 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections; +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml; +using Google.Api.Gax; +using Google.Cloud.Spanner.V1; +using Google.Protobuf.WellKnownTypes; +using TypeCode = Google.Cloud.Spanner.V1.TypeCode; + +namespace Google.Cloud.Spanner.DataProvider; + +public class SpannerParameter : DbParameter +{ + private DbType? _dbType; + + public override DbType DbType + { + get => _dbType ?? DbType.String; + set => _dbType = value; + } + + /// + /// SpannerParameterType overrides the standard DbType property with a specific Spanner type. + /// Use this property if you need to set a specific Spanner type that is not supported by DbType, such as + /// one of the Spanner array types. + /// + public V1.Type? SpannerParameterType { get; set; } + + public override ParameterDirection Direction { get; set; } = ParameterDirection.Input; + public override bool IsNullable { get; set; } + + private string _name = ""; + [AllowNull] public override string ParameterName + { + get => _name; + set => _name = value ?? ""; + } + + private string _sourceColumn = ""; + + [AllowNull] + public override string SourceColumn + { + get => _sourceColumn; + set => _sourceColumn = value ?? ""; + } + public override object? Value { get; set; } + public override bool SourceColumnNullMapping { get; set; } + public override int Size { get; set; } + public override DataRowVersion SourceVersion + { + get => DataRowVersion.Current; + set { } + } + + public override void ResetDbType() + { + _dbType = null; + } + + internal Value ConvertToProto() + { + GaxPreconditions.CheckState(Value != null, $"Parameter {ParameterName} has no value"); + return ConvertToProto(Value!); + } + + internal Google.Cloud.Spanner.V1.Type? GetSpannerType() + { + return SpannerParameterType ?? TypeConversion.GetSpannerType(_dbType); + } + + private Value ConvertToProto(object value) + { + var type = GetSpannerType(); + return ConvertToProto(value, type); + } + + private static Value ConvertToProto(object? value, Google.Cloud.Spanner.V1.Type? type) + { + var proto = new Value(); + switch (value) + { + case null: + case DBNull: + proto.NullValue = NullValue.NullValue; + break; + case bool b: + proto.BoolValue = b; + break; + case double d: + proto.NumberValue = d; + break; + case float f: + proto.NumberValue = f; + break; + case string str: + proto.StringValue = str; + break; + case Regex regex: + proto.StringValue = regex.ToString(); + break; + case byte b: + proto.StringValue = b.ToString(); + break; + case byte[] bytes: + proto.StringValue = Convert.ToBase64String(bytes); + break; + case MemoryStream memoryStream: + // TODO: Optimize this + proto.StringValue = Convert.ToBase64String(memoryStream.ToArray()); + break; + case short s: + proto.StringValue = s.ToString(); + break; + case int i: + proto.StringValue = i.ToString(); + break; + case long l: + proto.StringValue = l.ToString(); + break; + case decimal d: + proto.StringValue = d.ToString(CultureInfo.InvariantCulture); + break; + case SpannerNumeric num: + proto.StringValue = num.ToString(); + break; + case DateOnly d: + proto.StringValue = d.ToString("O"); + break; + case SpannerDate d: + proto.StringValue = d.ToString(); + break; + case DateTime d: + // Some framework pass DATE values as DateTime. + if (type?.Code == TypeCode.Date) + { + proto.StringValue = d.Date.ToString("yyyy-MM-dd"); + } + else + { + proto.StringValue = d.ToUniversalTime().ToString("O"); + } + break; + case TimeSpan t: + proto.StringValue = XmlConvert.ToString(t); + break; + case JsonDocument jd: + proto.StringValue = jd.RootElement.ToString(); + break; + case IEnumerable list: + var elementType = type?.ArrayElementType; + proto.ListValue = new ListValue(); + foreach (var item in list) + { + proto.ListValue.Values.Add(ConvertToProto(item, elementType)); + } + break; + default: + // Unknown type. Just try to send it as a string. + proto.StringValue = value.ToString(); + break; + } + return proto; + } + +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerParameterCollection.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerParameterCollection.cs new file mode 100644 index 00000000..9fc6734d --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerParameterCollection.cs @@ -0,0 +1,218 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Data.Common; +using Google.Api.Gax; +using Google.Protobuf.Collections; +using Google.Protobuf.WellKnownTypes; + +namespace Google.Cloud.Spanner.DataProvider; + +public class SpannerParameterCollection : DbParameterCollection +{ + private readonly List _params = new (); + public override int Count => _params.Count; + public override object SyncRoot => _params; + + public override int Add(object value) + { + GaxPreconditions.CheckNotNull(value, nameof(value)); + var index = _params.Count; + if (value is SpannerParameter spannerParameter) + { + _params.Add(spannerParameter); + } + else + { + _params.Add(new SpannerParameter { ParameterName = "p" + (index + 1), Value = value }); + } + + return index; + } + + public override void Clear() + { + _params.Clear(); + } + + public override bool Contains(object value) + { + return _params.Find(p => Equals(p.Value, value)) != null; + } + + public override int IndexOf(object value) + { + if (value is SpannerParameter spannerParameter) + { + return _params.IndexOf(spannerParameter); + } + return _params.FindIndex(p => Equals(p.Value, value)); + } + + public override void Insert(int index, object value) + { + GaxPreconditions.CheckNotNull(value, nameof(value)); + if (value is SpannerParameter spannerParameter) + { + _params.Insert(index, spannerParameter); + } + else + { + _params.Insert(index, new SpannerParameter { ParameterName = "p" + (index + 1), Value = value }); + } + } + + public override void Remove(object value) + { + GaxPreconditions.CheckNotNull(value, nameof(value)); + var index = IndexOf(value); + if (index > -1) + { + _params.RemoveAt(index); + } + } + + public override void RemoveAt(int index) + { + _params.RemoveAt(index); + } + + public override void RemoveAt(string parameterName) + { + var index = _params.FindIndex(p => Equals(p.ParameterName, parameterName)); + if (index > -1) + { + _params.RemoveAt(index); + } + } + + protected override void SetParameter(int index, DbParameter value) + { + GaxPreconditions.CheckNotNull(value, nameof(value)); + if (value is SpannerParameter spannerParameter) + { + _params[index] = spannerParameter; + } + else + { + throw new ArgumentException("value is not a SpannerParameter"); + } + } + + protected override void SetParameter(string parameterName, DbParameter value) + { + GaxPreconditions.CheckNotNull(value, nameof(value)); + if (value is SpannerParameter spannerParameter) + { + var index = _params.FindIndex(p => Equals(p.ParameterName, parameterName)); + if (index > -1) + { + _params[index] = spannerParameter; + } + } + else + { + throw new ArgumentException("value is not a SpannerParameter"); + } + } + + public override int IndexOf(string parameterName) + { + return _params.FindIndex(p => Equals(p.ParameterName, parameterName)); + } + + public override bool Contains(string value) + { + return _params.Find(p => Equals(p.Value, value)) != null; + } + + public override void CopyTo(Array array, int index) + { + if (array == null) + { + throw new ArgumentNullException(nameof(array)); + } + + if (array.Length < _params.Count + index) + { + throw new ArgumentOutOfRangeException( + nameof(array), "There is not enough space in the array to copy values."); + } + + foreach (var item in _params) + { + array.SetValue(item, index); + index++; + } + } + + public override IEnumerator GetEnumerator() + { + return _params.GetEnumerator(); + } + + protected override DbParameter GetParameter(int index) + { + return _params[index]; + } + + protected override DbParameter GetParameter(string parameterName) + { + return _params.Find(p => Equals(p.ParameterName, parameterName)); + } + + public override void AddRange(Array values) + { + foreach (var value in values) + { + Add(value); + } + } + + internal Tuple> CreateSpannerParams() + { + var queryParams = new Struct(); + var paramTypes = new MapField(); + for (var index = 0; index < Count; index++) + { + var param = this[index]; + if (param is SpannerParameter spannerParameter) + { + var name = param.ParameterName; + if (name.StartsWith("@")) + { + name = name[1..]; + } + else if (name.StartsWith("$")) + { + name = "p" + name[1..]; + } + queryParams.Fields.Add(name, spannerParameter.ConvertToProto()); + var paramType = spannerParameter.GetSpannerType(); + if (paramType != null) + { + paramTypes.Add(name, paramType); + } + } + else + { + throw new InvalidOperationException("parameter is not a SpannerParameter: " + param.ParameterName); + } + } + return Tuple.Create(queryParams, paramTypes); + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerPool.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerPool.cs new file mode 100644 index 00000000..6d401971 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerPool.cs @@ -0,0 +1,97 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Google.Cloud.SpannerLib; +using Google.Cloud.SpannerLib.Grpc; +using Google.Cloud.SpannerLib.Native.Impl; + +namespace Google.Cloud.Spanner.DataProvider; + +internal class SpannerPool +{ + private static ISpannerLib? _gRpcSpannerLib; + + private static ISpannerLib GrpcSpannerLib + { + get + { + _gRpcSpannerLib ??= new GrpcLibSpanner(); + return _gRpcSpannerLib; + } + } + + private static ISpannerLib? _nativeSpannerLib; + + private static ISpannerLib NativeSpannerLib + { + get + { + _nativeSpannerLib ??= new SharedLibSpanner(); + return _nativeSpannerLib; + } + } + + private static readonly ConcurrentDictionary Pools = new(); + + [MethodImpl(MethodImplOptions.Synchronized)] + internal static SpannerPool GetOrCreate(string dsn, bool useNativeLibrary = false) + { + if (Pools.TryGetValue(dsn, out var value)) + { + return value; + } + var pool = Pool.Create(useNativeLibrary ? NativeSpannerLib : GrpcSpannerLib, dsn); + var spannerPool = new SpannerPool(dsn, pool); + Pools[dsn] = spannerPool; + return spannerPool; + } + + [MethodImpl(MethodImplOptions.Synchronized)] + internal static void CloseSpannerLib() + { + foreach (var pool in Pools.Values) + { + pool.Close(); + } + Pools.Clear(); + GrpcSpannerLib.Dispose(); + _gRpcSpannerLib = null; + NativeSpannerLib.Dispose(); + _nativeSpannerLib = null; + } + + private readonly string _dsn; + + private readonly Pool _libPool; + + private SpannerPool(string dsn, Pool libPool) + { + _dsn = dsn; + _libPool = libPool; + } + + internal void Close() + { + _libPool.Close(); + Pools.Remove(_dsn, out _); + } + + internal Connection CreateConnection() + { + return _libPool.CreateConnection(); + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/SpannerTransaction.cs b/drivers/spanner-ado-net/spanner-ado-net/SpannerTransaction.cs new file mode 100644 index 00000000..5852fd21 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/SpannerTransaction.cs @@ -0,0 +1,149 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Google.Api.Gax; +using Google.Cloud.Spanner.V1; + +namespace Google.Cloud.Spanner.DataProvider; + +public class SpannerTransaction : DbTransaction +{ + private SpannerConnection? _spannerConnection; + + protected override DbConnection? DbConnection => _spannerConnection; + public override IsolationLevel IsolationLevel { get; } + private SpannerLib.Connection LibConnection { get; } + + private bool _disposed; + + internal SpannerTransaction(SpannerConnection connection, TransactionOptions options) + { + _spannerConnection = connection; + IsolationLevel = TranslateIsolationLevel(options.IsolationLevel); + LibConnection = connection.LibConnection!; + LibConnection.BeginTransaction(options); + } + + internal static TransactionOptions.Types.IsolationLevel TranslateIsolationLevel(IsolationLevel isolationLevel) + { + return isolationLevel switch + { + IsolationLevel.Chaos => throw new NotSupportedException(), + IsolationLevel.ReadUncommitted => throw new NotSupportedException(), + IsolationLevel.ReadCommitted => throw new NotSupportedException(), + IsolationLevel.RepeatableRead => TransactionOptions.Types.IsolationLevel.RepeatableRead, + IsolationLevel.Snapshot => TransactionOptions.Types.IsolationLevel.RepeatableRead, + IsolationLevel.Serializable => TransactionOptions.Types.IsolationLevel.Serializable, + _ => TransactionOptions.Types.IsolationLevel.Unspecified + }; + } + + private static IsolationLevel TranslateIsolationLevel(TransactionOptions.Types.IsolationLevel isolationLevel) + { + switch (isolationLevel) + { + case TransactionOptions.Types.IsolationLevel.Unspecified: + return IsolationLevel.Unspecified; + case TransactionOptions.Types.IsolationLevel.RepeatableRead: + return IsolationLevel.RepeatableRead; + case TransactionOptions.Types.IsolationLevel.Serializable: + return IsolationLevel.Serializable; + default: + throw new ArgumentOutOfRangeException(nameof(isolationLevel), isolationLevel, + "unsupported isolation level"); + } + } + + protected override void Dispose(bool disposing) + { + if (_spannerConnection != null) + { + // Do a shoot-and-forget rollback. + RollbackAsync(CancellationToken.None); + } + _disposed = true; + base.Dispose(disposing); + } + + public override ValueTask DisposeAsync() + { + if (_spannerConnection != null) + { + // Do a shoot-and-forget rollback. + RollbackAsync(CancellationToken.None); + } + _disposed = true; + return base.DisposeAsync(); + } + + private void CheckDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + public override void Commit() + { + EndTransaction(() => LibConnection.Commit()); + } + + public override Task CommitAsync(CancellationToken cancellationToken = default) + { + return EndTransactionAsync(() => LibConnection.CommitAsync(cancellationToken)); + } + + public override void Rollback() + { + EndTransaction(() => LibConnection.Rollback()); + } + + public override Task RollbackAsync(CancellationToken cancellationToken = default) + { + return EndTransactionAsync(() => LibConnection.RollbackAsync(cancellationToken)); + } + + private void EndTransaction(Action endTransactionMethod) + { + CheckDisposed(); + GaxPreconditions.CheckState(_spannerConnection is not null, "This transaction is no longer active"); + try + { + endTransactionMethod(); + } + finally + { + _spannerConnection?.ClearTransaction(); + _spannerConnection = null; + } + } + + private Task EndTransactionAsync(Func endTransactionMethod) + { + CheckDisposed(); + GaxPreconditions.CheckState(_spannerConnection is not null, "This transaction is no longer active"); + try + { + return endTransactionMethod(); + } + finally + { + _spannerConnection?.ClearTransaction(); + _spannerConnection = null; + } + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/TypeConversion.cs b/drivers/spanner-ado-net/spanner-ado-net/TypeConversion.cs new file mode 100644 index 00000000..c31c4fc5 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/TypeConversion.cs @@ -0,0 +1,87 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Data; +using TypeCode = Google.Cloud.Spanner.V1.TypeCode; + +namespace Google.Cloud.Spanner.DataProvider; + +internal static class TypeConversion +{ + private static readonly Dictionary SDbTypeToSpannerTypeMapping = new (); + + static TypeConversion() + { + SDbTypeToSpannerTypeMapping[DbType.Date] = new V1.Type { Code = TypeCode.Date }; + SDbTypeToSpannerTypeMapping[DbType.Binary] = new V1.Type { Code = TypeCode.Bytes }; + SDbTypeToSpannerTypeMapping[DbType.Boolean] = new V1.Type { Code = TypeCode.Bool }; + SDbTypeToSpannerTypeMapping[DbType.Double] = new V1.Type { Code = TypeCode.Float64 }; + SDbTypeToSpannerTypeMapping[DbType.Single] = new V1.Type { Code = TypeCode.Float32 }; + SDbTypeToSpannerTypeMapping[DbType.Guid] = new V1.Type { Code = TypeCode.Uuid }; + + var numericType = new V1.Type { Code = TypeCode.Numeric }; + SDbTypeToSpannerTypeMapping[DbType.Decimal] = numericType; + SDbTypeToSpannerTypeMapping[DbType.VarNumeric] = numericType; + + var timestampType = new V1.Type { Code = TypeCode.Timestamp }; + SDbTypeToSpannerTypeMapping[DbType.DateTime] = timestampType; + SDbTypeToSpannerTypeMapping[DbType.DateTime2] = timestampType; + SDbTypeToSpannerTypeMapping[DbType.DateTimeOffset] = timestampType; + + var int64Type = new V1.Type { Code = TypeCode.Int64 }; + SDbTypeToSpannerTypeMapping[DbType.Byte] = int64Type; + SDbTypeToSpannerTypeMapping[DbType.Int16] = int64Type; + SDbTypeToSpannerTypeMapping[DbType.Int32] = int64Type; + SDbTypeToSpannerTypeMapping[DbType.Int64] = int64Type; + SDbTypeToSpannerTypeMapping[DbType.SByte] = int64Type; + SDbTypeToSpannerTypeMapping[DbType.UInt16] = int64Type; + SDbTypeToSpannerTypeMapping[DbType.UInt32] = int64Type; + SDbTypeToSpannerTypeMapping[DbType.UInt64] = int64Type; + + var stringType = new V1.Type { Code = TypeCode.String }; + SDbTypeToSpannerTypeMapping[DbType.String] = stringType; + SDbTypeToSpannerTypeMapping[DbType.StringFixedLength] = stringType; + SDbTypeToSpannerTypeMapping[DbType.AnsiString] = stringType; + SDbTypeToSpannerTypeMapping[DbType.AnsiStringFixedLength] = stringType; + } + + internal static V1.Type? GetSpannerType(DbType? dbType) + { + return dbType == null ? null : SDbTypeToSpannerTypeMapping.GetValueOrDefault(dbType.Value); + } + + internal static System.Type GetSystemType(V1.Type type) + { + return type.Code switch + { + TypeCode.Bool => typeof(bool), + TypeCode.Bytes => typeof(byte[]), + TypeCode.Date => typeof(DateOnly), + TypeCode.Enum => typeof(long), + TypeCode.Float32 => typeof(float), + TypeCode.Float64 => typeof(double), + TypeCode.Int64 => typeof(long), + TypeCode.Interval => typeof(TimeSpan), + TypeCode.Json => typeof(string), + TypeCode.Numeric => typeof(decimal), + TypeCode.Proto => typeof(byte[]), + TypeCode.String => typeof(string), + TypeCode.Timestamp => typeof(DateTime), + TypeCode.Uuid => typeof(Guid), + _ => typeof(string) + }; + } +} \ No newline at end of file diff --git a/drivers/spanner-ado-net/spanner-ado-net/publish.sh b/drivers/spanner-ado-net/spanner-ado-net/publish.sh new file mode 100644 index 00000000..4425ca25 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/publish.sh @@ -0,0 +1,11 @@ +VERSION=$(date -u +"1.0.0-alpha.%Y%m%d%H%M%S") + +echo "Publishing as version $VERSION" +sed -i "" "s|.*|$VERSION|g" spanner-ado-net.csproj + +rm -rf bin/Release +dotnet pack +dotnet nuget push \ + bin/Release/Alpha.Google.Cloud.Spanner.DataProvider.*.nupkg \ + --api-key $NUGET_API_KEY \ + --source https://api.nuget.org/v3/index.json diff --git a/drivers/spanner-ado-net/spanner-ado-net/spanner-ado-net.csproj b/drivers/spanner-ado-net/spanner-ado-net/spanner-ado-net.csproj new file mode 100644 index 00000000..d27cbaf3 --- /dev/null +++ b/drivers/spanner-ado-net/spanner-ado-net/spanner-ado-net.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + Google.Cloud.Spanner.DataProvider + enable + default + Google.Cloud.Spanner.DataProvider + Alpha.Google.Cloud.Spanner.DataProvider + .NET Data Provider for Spanner + Google + 1.0.0-alpha.20251003170157 + ADO.NET Data Provider. + +Alpha version: Not for production use + Apache v2.0 + https://github.com/googleapis/go-sql-spanner/drivers/spanner-ado-net/LICENSE + https://github.com/googleapis/go-sql-spanner/drivers/spanner-ado-net + + + + + + + + + +