├── .nuke ├── .tmp ├── build-attempt.log └── shell-completion.yml ├── .gitignore ├── FunnyDB ├── FunnyDB.csproj ├── ISession.cs ├── DbSqlQueryParameter.cs ├── SqlQueryParameter.cs ├── ISqlResultSet.cs ├── SqlQueryExtensions.cs ├── SqlQuery.cs ├── SqlQueryValue.cs ├── Dialect.cs ├── SqlQueryResult.cs └── SqlQueryLinter.cs ├── FunnyDB.Postgres ├── FunnyDB.Postgres.csproj ├── PgValue.cs ├── PgParameter.cs └── Session.cs ├── FunnyDB.Test ├── FunnyDB.Test.csproj ├── SqlStrongTypeParametersTests.cs ├── SqlQueryReuseStatementsTest.cs ├── SqlLinterTests.cs ├── SqlQueryTests.cs └── SqlIntegrationTest.cs ├── FunnyDB.Bench └── CachedQueryVsGeneratedQuery.cs ├── license ├── FunnyDB.sln └── readme.md /.nuke: -------------------------------------------------------------------------------- 1 | FunnyDB.sln -------------------------------------------------------------------------------- /.tmp/build-attempt.log: -------------------------------------------------------------------------------- 1 | 4ef7baa7e774856c52cae4ab3ddbc635 2 | Restore 3 | Compile 4 | Test 5 | Pack 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.idea/ 2 | *.suo 3 | *.user 4 | .vs/ 5 | [Bb]in/ 6 | [Oo]bj/ 7 | _UpgradeReport_Files/ 8 | [Pp]ackages/ 9 | 10 | Thumbs.db 11 | Desktop.ini 12 | .DS_Store -------------------------------------------------------------------------------- /FunnyDB/FunnyDB.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /FunnyDB/ISession.cs: -------------------------------------------------------------------------------- 1 | using System.Data; 2 | 3 | namespace FunnyDB 4 | { 5 | public interface ISession 6 | { 7 | IDbConnection Connection { get; } 8 | IDbTransaction Transaction { get; } 9 | void Commit(); 10 | void Rollback(); 11 | } 12 | } -------------------------------------------------------------------------------- /FunnyDB.Postgres/FunnyDB.Postgres.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /.tmp/shell-completion.yml: -------------------------------------------------------------------------------- 1 | Configuration: 2 | - Debug 3 | - Release 4 | Continue: 5 | Help: 6 | Host: 7 | - AppVeyor 8 | - AzurePipelines 9 | - Bamboo 10 | - Bitrise 11 | - Console 12 | - GitHubActions 13 | - GitLab 14 | - Jenkins 15 | - TeamCity 16 | - Travis 17 | NoLogo: 18 | Plan: 19 | Root: 20 | Skip: 21 | - Clean 22 | - Compile 23 | - Pack 24 | - Restore 25 | - Test 26 | Target: 27 | - Clean 28 | - Compile 29 | - Pack 30 | - Restore 31 | - Test 32 | Verbosity: 33 | - Minimal 34 | - Normal 35 | - Quiet 36 | - Verbose 37 | -------------------------------------------------------------------------------- /FunnyDB.Test/FunnyDB.Test.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /FunnyDB.Bench/CachedQueryVsGeneratedQuery.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using BenchmarkDotNet.Attributes; 3 | 4 | namespace FunnyDB.Bench 5 | { 6 | [SimpleJob] 7 | public class CachedQueryVsGeneratedQuery 8 | { 9 | [GlobalSetup] 10 | public void Setup() 11 | { 12 | } 13 | 14 | // ReSharper disable once InconsistentNaming 15 | private static Func reusable(Func, SqlQuery> builder) 16 | { 17 | T1 p1Holder = default; 18 | SqlQuery query = null; 19 | 20 | var f = new Func(p1 => 21 | { 22 | if (query == null) 23 | { 24 | query = builder(() => p1Holder); 25 | } 26 | 27 | p1Holder = p1; 28 | return query; 29 | }); 30 | 31 | return f; 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /FunnyDB.Postgres/PgValue.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using NpgsqlTypes; 3 | 4 | namespace FunnyDB.Postgres 5 | { 6 | public sealed class PgValue 7 | { 8 | public PgValue(NpgsqlDbType dbType, Func value) 9 | { 10 | DbType = dbType; 11 | Value = value; 12 | } 13 | 14 | public readonly NpgsqlDbType DbType; 15 | 16 | public readonly Func Value; 17 | 18 | private bool Equals(PgValue other) 19 | { 20 | return DbType == other.DbType && Equals(Value, other.Value); 21 | } 22 | 23 | public override bool Equals(object obj) 24 | { 25 | return ReferenceEquals(this, obj) || obj is PgValue other && Equals(other); 26 | } 27 | 28 | public override int GetHashCode() 29 | { 30 | unchecked 31 | { 32 | return ((int) DbType * 397) ^ (Value != null ? Value.GetHashCode() : 0); 33 | } 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Kirill Volkov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /FunnyDB/DbSqlQueryParameter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data; 3 | 4 | namespace FunnyDB 5 | { 6 | internal sealed class DbSqlQueryParameter : SqlQueryParameter 7 | { 8 | public DbSqlQueryParameter(int index, DbType dbType, Func value) : base(index) 9 | { 10 | DbType = dbType; 11 | Value = value; 12 | } 13 | 14 | public DbSqlQueryParameter(string name, DbType dbType, Func value) : base(name) 15 | { 16 | DbType = dbType; 17 | Value = value; 18 | } 19 | 20 | public readonly DbType DbType; 21 | 22 | public override Func Value { get; } 23 | 24 | public override SqlQueryParameter ChangeIndex(int index) 25 | { 26 | return new DbSqlQueryParameter(index, DbType, Value); 27 | } 28 | 29 | public override void AddToCommand(IDbCommand command) 30 | { 31 | var p = command.CreateParameter(); 32 | p.ParameterName = Name; 33 | p.DbType = DbType; 34 | p.Value = Value(); 35 | command.Parameters.Add(p); 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /FunnyDB.Test/SqlStrongTypeParametersTests.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using FluentAssertions; 3 | using NUnit.Framework; 4 | using static FunnyDB.Dialect; 5 | 6 | namespace Domain 7 | { 8 | public class AccountId 9 | { 10 | public AccountId(long value) 11 | { 12 | Value = value; 13 | } 14 | 15 | public readonly long Value; 16 | } 17 | 18 | public static class Dialect 19 | { 20 | // ReSharper disable once InconsistentNaming 21 | public static string p(AccountId accountId) => FunnyDB.Dialect.p(accountId.Value); 22 | } 23 | } 24 | 25 | namespace FunnyDB.Test 26 | { 27 | using Domain; 28 | using static Domain.Dialect; 29 | 30 | [TestFixture] 31 | public class SqlStrongTypeParametersTests 32 | { 33 | [Test] 34 | public void SqlQuery_ShouldBeExtendableWithDomainModelTypes() 35 | { 36 | var accountId = new AccountId(1); 37 | var query = sql(() => $"SELECT balance FROM accounts WHERE id = {p(accountId)}"); 38 | query.Sql.Should().Be("SELECT balance FROM accounts WHERE id = @p_0_"); 39 | query.Parameters.Count.Should().Be(1); 40 | query.Parameters.First().Value().Should().Be(accountId.Value); 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /FunnyDB.Postgres/PgParameter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data; 3 | using Npgsql; 4 | 5 | namespace FunnyDB.Postgres 6 | { 7 | public class PgParameter : SqlQueryParameter 8 | { 9 | private readonly PgValue _pgValue; 10 | 11 | public PgParameter(string name, PgValue value) : base(name) 12 | { 13 | _pgValue = value; 14 | } 15 | 16 | public PgParameter(int index, PgValue value) : base(index) 17 | { 18 | _pgValue = value; 19 | } 20 | 21 | public override Func Value => _pgValue.Value; 22 | 23 | public override SqlQueryParameter ChangeIndex(int index) 24 | { 25 | return new PgParameter(index, _pgValue); 26 | } 27 | 28 | public override void AddToCommand(IDbCommand command) 29 | { 30 | if (!(command is NpgsqlCommand pgCommand)) 31 | { 32 | throw new InvalidOperationException( 33 | $"Can't add parameter to command with type '{command.GetType()}'. " + 34 | $"'{typeof(NpgsqlCommand)}' expected."); 35 | } 36 | 37 | var p = pgCommand.CreateParameter(); 38 | p.ParameterName = Name; 39 | p.NpgsqlDbType = _pgValue.DbType; 40 | p.Value = _pgValue.Value(); 41 | 42 | pgCommand.Parameters.Add(p); 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /FunnyDB.Test/SqlQueryReuseStatementsTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using FluentAssertions; 4 | using NUnit.Framework; 5 | using static FunnyDB.Dialect; 6 | 7 | namespace FunnyDB.Test 8 | { 9 | [TestFixture] 10 | public sealed class SqlQueryReuseStatementsTest 11 | { 12 | [Test] 13 | public void SqlQuery_ShouldAllowToReuseQueries() 14 | { 15 | var findAccount = FindAccount(); 16 | 17 | var q1 = findAccount("account_1"); 18 | q1.Parameters.ToArray()[0].Value().Should().Be("account_1"); 19 | 20 | var q2 = findAccount("account_2"); 21 | q2.Parameters.ToArray()[0].Value().Should().Be("account_2"); 22 | } 23 | 24 | private static Func FindAccount() 25 | { 26 | return reusable(accountId => sql(() => $"SELECT * FROM accounts WHERE {p(accountId)}")); 27 | } 28 | 29 | // ReSharper disable once InconsistentNaming 30 | private static Func reusable(Func, SqlQuery> builder) 31 | { 32 | T1 p1Holder = default; 33 | SqlQuery query = null; 34 | 35 | var f = new Func(p1 => 36 | { 37 | if (query == null) 38 | { 39 | // ReSharper disable once AccessToModifiedClosure 40 | query = builder(() => p1Holder); 41 | } 42 | 43 | p1Holder = p1; 44 | return query; 45 | }); 46 | 47 | return f; 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /FunnyDB/SqlQueryParameter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data; 3 | 4 | namespace FunnyDB 5 | { 6 | /// 7 | /// Represents a platform independent SQL query parameter. 8 | /// 9 | public abstract class SqlQueryParameter 10 | { 11 | private static readonly string[] IndexName; 12 | internal const int PinnedNameIndex = -1; 13 | 14 | static SqlQueryParameter() 15 | { 16 | var indexName = new string[1000]; 17 | for (var i = 0; i < indexName.Length; i++) 18 | { 19 | indexName[i] = $"p_{i}_"; 20 | } 21 | 22 | IndexName = indexName; 23 | } 24 | 25 | protected SqlQueryParameter(int index) 26 | : this(index, GetIndexName(index)) 27 | { 28 | } 29 | 30 | protected SqlQueryParameter(string name) 31 | : this(PinnedNameIndex, name) 32 | { 33 | } 34 | 35 | private SqlQueryParameter(int index, string name) 36 | { 37 | Index = index; 38 | Name = name; 39 | } 40 | 41 | public readonly int Index; 42 | public readonly string Name; 43 | public abstract Func Value { get; } 44 | 45 | public abstract SqlQueryParameter ChangeIndex(int index); 46 | public abstract void AddToCommand(IDbCommand command); 47 | 48 | private static string GetIndexName(int index) 49 | { 50 | var indexName = IndexName; 51 | if (index >= 0 && index < indexName.Length) 52 | { 53 | return indexName[index]; 54 | } 55 | 56 | return $"p_{index}_"; 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /FunnyDB/ISqlResultSet.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data; 3 | 4 | namespace FunnyDB 5 | { 6 | public interface ISqlResultSet 7 | { 8 | IDataReader AsDataReader(); 9 | int Int(string name); 10 | int Int(string name, int defaultValue); 11 | int Int(int ordinal); 12 | int Int(int ordinal, int defaultValue); 13 | long Long(string name); 14 | long Long(string name, long defaultValue); 15 | long Long(int ordinal); 16 | long Long(int ordinal, long defaultValue); 17 | float Float(string name); 18 | float Float(string name, float defaultValue); 19 | float Float(int ordinal); 20 | float Float(int ordinal, float defaultValue); 21 | double Double(string name); 22 | double Double(string name, double defaultValue); 23 | double Double(int ordinal); 24 | double Double(int ordinal, double defaultValue); 25 | decimal Decimal(string name); 26 | decimal Decimal(string name, decimal defaultValue); 27 | decimal Decimal(int ordinal); 28 | decimal Decimal(int ordinal, decimal defaultValue); 29 | string String(string name); 30 | string String(string name, string defaultValue); 31 | string String(int ordinal); 32 | string String(int ordinal, string defaultValue); 33 | bool Bool(string name); 34 | bool Bool(string name, bool defaultValue); 35 | bool Bool(int ordinal); 36 | bool Bool(int ordinal, bool defaultValue); 37 | DateTime DateTime(string name); 38 | DateTime DateTime(string name, DateTime defaultValue); 39 | DateTime DateTime(int ordinal); 40 | DateTime DateTime(int ordinal, DateTime defaultValue); 41 | Guid Guid(string name); 42 | Guid Guid(string name, Guid defaultValue); 43 | Guid Guid(int ordinal); 44 | Guid Guid(int ordinal, Guid defaultValue); 45 | } 46 | } -------------------------------------------------------------------------------- /FunnyDB.Test/SqlLinterTests.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Linq; 3 | using FluentAssertions; 4 | using NUnit.Framework; 5 | using static FunnyDB.Dialect; 6 | 7 | namespace FunnyDB.Test 8 | { 9 | [TestFixture] 10 | public class SqlLinterTests 11 | { 12 | [Test] 13 | public void SqlLinter_ShouldFoundErrors() 14 | { 15 | var codeWithError = @" 16 | [Test] 17 | public void SqlQuery_ShouldUseNamedParameters() 18 | { 19 | var accountId = (777, ""account_id""); 20 | 21 | var query = sql(() => $@"" 22 | |SELECT event_time, balance 23 | | FROM charges 24 | | WHERE account_id = {p(accountId)} 25 | | UNION ALL 26 | |SELECT event_time, balance 27 | | FROM withdraws 28 | | WHERE account_id = {accountId}""); 29 | "; 30 | 31 | SqlQueryLinter.Validate(codeWithError, out var errors).Should().BeFalse(); 32 | errors.Count.Should().Be(1); 33 | errors.First().Line.Should().Be(14); 34 | errors.First().Position.Should().Be(30); 35 | } 36 | 37 | [Test] 38 | public void SqlLinter_ShouldFoundErrorsInFolder() 39 | { 40 | var accountId = (777, "account_id"); 41 | 42 | sql(() => $@" 43 | |SELECT event_time, balance 44 | | FROM charges 45 | | WHERE account_id = {p(accountId)} 46 | | UNION ALL 47 | |SELECT event_time, balance 48 | | FROM withdraws 49 | | WHERE account_id = {accountId}"); // <----------- ERROR RIGHT HERE 50 | 51 | var assemblyPath = GetType().Assembly.Location; 52 | var projectFolder = Enumerable.Range(0, 4).Aggregate(assemblyPath, (p, _) => Path.GetDirectoryName(p)); 53 | SqlQueryLinter.ValidateFolder(projectFolder, out var errors).Should().BeFalse(); 54 | errors.Count.Should().Be(1); 55 | errors.First().Path.Should().Be(Path.Combine(projectFolder, "SqlLinterTests.cs")); 56 | errors.First().Error.Line.Should().Be(49); 57 | errors.First().Error.Position.Should().Be(38); 58 | } 59 | } 60 | 61 | } -------------------------------------------------------------------------------- /FunnyDB.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunnyDB", "FunnyDB\FunnyDB.csproj", "{6BA0FD3A-82FB-4366-AE98-4BE931817056}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunnyDB.Test", "FunnyDB.Test\FunnyDB.Test.csproj", "{678422A7-22F9-4508-815E-5A0A540E56B4}" 6 | EndProject 7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunnyDB.Postgres", "FunnyDB.Postgres\FunnyDB.Postgres.csproj", "{CE8DF31B-CE8E-48EC-843B-F3029E046B4E}" 8 | EndProject 9 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{14C38539-00E8-4A50-96B8-3E0BAD9D08A3}" 10 | ProjectSection(SolutionItems) = preProject 11 | readme.md = readme.md 12 | EndProjectSection 13 | EndProject 14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build", "build\_build.csproj", "{43550B0B-530F-4F49-8AF2-7AEB5585D3FC}" 15 | EndProject 16 | Global 17 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 18 | Debug|Any CPU = Debug|Any CPU 19 | Release|Any CPU = Release|Any CPU 20 | EndGlobalSection 21 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 22 | {43550B0B-530F-4F49-8AF2-7AEB5585D3FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {43550B0B-530F-4F49-8AF2-7AEB5585D3FC}.Release|Any CPU.ActiveCfg = Release|Any CPU 24 | {6BA0FD3A-82FB-4366-AE98-4BE931817056}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {6BA0FD3A-82FB-4366-AE98-4BE931817056}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {6BA0FD3A-82FB-4366-AE98-4BE931817056}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {6BA0FD3A-82FB-4366-AE98-4BE931817056}.Release|Any CPU.Build.0 = Release|Any CPU 28 | {678422A7-22F9-4508-815E-5A0A540E56B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {678422A7-22F9-4508-815E-5A0A540E56B4}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {678422A7-22F9-4508-815E-5A0A540E56B4}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {678422A7-22F9-4508-815E-5A0A540E56B4}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {CE8DF31B-CE8E-48EC-843B-F3029E046B4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {CE8DF31B-CE8E-48EC-843B-F3029E046B4E}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {CE8DF31B-CE8E-48EC-843B-F3029E046B4E}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {CE8DF31B-CE8E-48EC-843B-F3029E046B4E}.Release|Any CPU.Build.0 = Release|Any CPU 36 | EndGlobalSection 37 | EndGlobal 38 | -------------------------------------------------------------------------------- /FunnyDB/SqlQueryExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data; 3 | 4 | namespace FunnyDB 5 | { 6 | public static class SqlQueryExtensions 7 | { 8 | public static SqlQueryResult Execute(this SqlQuery sql, ISession session) 9 | { 10 | var connection = session.Connection; 11 | var transaction = session.Transaction; 12 | return sql.Execute(connection, transaction, autoOpen: false); 13 | } 14 | 15 | public static SqlQueryResult Execute( 16 | this SqlQuery sql, 17 | IDbConnection connection, 18 | IDbTransaction transaction = null, 19 | bool autoOpen = true) 20 | { 21 | var reader = Execute(sql, connection, transaction, autoOpen, cmd => cmd.ExecuteReader()); 22 | return new SqlQueryResult(reader); 23 | } 24 | 25 | public static T ExecuteScalar(this SqlQuery sql, ISession session) 26 | { 27 | var connection = session.Connection; 28 | var transaction = session.Transaction; 29 | return sql.ExecuteScalar(connection, transaction, autoOpen: false); 30 | } 31 | 32 | public static T ExecuteScalar( 33 | this SqlQuery sql, 34 | IDbConnection connection, 35 | IDbTransaction transaction = null, 36 | bool autoOpen = true) 37 | { 38 | return (T) Execute(sql, connection, transaction, autoOpen, cmd => cmd.ExecuteScalar()); 39 | } 40 | 41 | public static int ExecuteNonQuery(this SqlQuery sql, ISession session) 42 | { 43 | var connection = session.Connection; 44 | var transaction = session.Transaction; 45 | return sql.ExecuteNonQuery(connection, transaction, autoOpen: false); 46 | } 47 | 48 | 49 | public static int ExecuteNonQuery( 50 | this SqlQuery sql, 51 | IDbConnection connection, 52 | IDbTransaction transaction = null, 53 | bool autoOpen = true) 54 | { 55 | return Execute(sql, connection, transaction, autoOpen, cmd => cmd.ExecuteNonQuery()); 56 | } 57 | 58 | private static T Execute( 59 | SqlQuery sql, 60 | IDbConnection connection, 61 | IDbTransaction transaction, 62 | bool autoOpen, 63 | Func f) 64 | { 65 | if (autoOpen && connection.State != ConnectionState.Open) 66 | { 67 | connection.Open(); 68 | } 69 | 70 | var cmd = connection.CreateCommand(); 71 | cmd.CommandText = sql.Sql; 72 | foreach (var parameter in sql.Parameters) 73 | { 74 | parameter.AddToCommand(cmd); 75 | } 76 | 77 | if (transaction != null) 78 | { 79 | cmd.Transaction = transaction; 80 | } 81 | 82 | return f(cmd); 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /FunnyDB.Postgres/Session.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data; 3 | using Npgsql; 4 | 5 | namespace FunnyDB.Postgres 6 | { 7 | public class Session : ISession 8 | { 9 | public static void Tx(string connectionString, Action body, IsolationLevel? level = null) 10 | { 11 | Tx(connectionString, level: level, body: session => 12 | { 13 | body(session); 14 | return null; 15 | }); 16 | } 17 | 18 | public static T Tx(string connectionString, Func body, IsolationLevel? level = null) 19 | { 20 | using (var connection = new NpgsqlConnection(connectionString)) 21 | { 22 | connection.Open(); 23 | using (var transaction = BeginTransaction(connection, level)) 24 | { 25 | try 26 | { 27 | var session = new Session(connection, transaction); 28 | var result = body(session); 29 | transaction.Commit(); 30 | return result; 31 | } 32 | catch (Exception) 33 | { 34 | try 35 | { 36 | transaction.Rollback(); 37 | } 38 | catch (Exception) 39 | { 40 | // ignore; 41 | } 42 | 43 | throw; 44 | } 45 | } 46 | } 47 | } 48 | 49 | public static void AutoCommit(string connectionString, Action body) 50 | { 51 | AutoCommit(connectionString, session => 52 | { 53 | body(session); 54 | return null; 55 | }); 56 | } 57 | 58 | public static T AutoCommit(string connectionString, Func body) 59 | { 60 | using (var connection = new NpgsqlConnection(connectionString)) 61 | { 62 | connection.Open(); 63 | var session = new Session(connection, null); 64 | var result = body(session); 65 | return result; 66 | } 67 | } 68 | 69 | private static NpgsqlTransaction BeginTransaction(NpgsqlConnection connection, IsolationLevel? level = null) 70 | { 71 | return level == null ? connection.BeginTransaction() : connection.BeginTransaction(level.Value); 72 | } 73 | 74 | private Session(IDbConnection connection, IDbTransaction transaction) 75 | { 76 | Connection = connection; 77 | Transaction = transaction; 78 | } 79 | 80 | public IDbConnection Connection { get; } 81 | 82 | public IDbTransaction Transaction { get; } 83 | 84 | public void Commit() 85 | { 86 | Transaction.Commit(); 87 | } 88 | 89 | public void Rollback() 90 | { 91 | Transaction.Rollback(); 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /FunnyDB.Test/SqlQueryTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using FluentAssertions; 4 | using NUnit.Framework; 5 | using static FunnyDB.Dialect; 6 | 7 | namespace FunnyDB.Test 8 | { 9 | [TestFixture] 10 | public class SqlQueryTests 11 | { 12 | [Test] 13 | public void SqlQuery_ShouldGenerateParameters() 14 | { 15 | var toDate = DateTime.Now.Date; 16 | var fromDate = toDate - TimeSpan.FromDays(30); 17 | var balance = 100; 18 | 19 | var query = sql(() => $@" 20 | |SELECT * 21 | | FROM accounts 22 | | WHERE updated_at BETWEEN {p(fromDate)} AND {p(toDate)} 23 | | AND balance >= {p(balance)}"); 24 | 25 | query.Sql.Should().Be( 26 | @"SELECT * 27 | FROM accounts 28 | WHERE updated_at BETWEEN @p_0_ AND @p_1_ 29 | AND balance >= @p_2_"); 30 | 31 | var parameters = query.Parameters.ToList(); 32 | parameters.Count.Should().Be(3); 33 | 34 | parameters[0].Name.Should().Be("p_0_"); 35 | parameters[0].Value().Should().Be(fromDate); 36 | parameters[1].Name.Should().Be("p_1_"); 37 | parameters[1].Value().Should().Be(toDate); 38 | parameters[2].Name.Should().Be("p_2_"); 39 | parameters[2].Value().Should().Be(balance); 40 | } 41 | 42 | [Test] 43 | public void SqlQuery_ShouldUseNamedParameters() 44 | { 45 | var accountId = (777, "account_id"); 46 | 47 | var query = sql(() => $@" 48 | |SELECT event_time, balance 49 | | FROM charges 50 | | WHERE account_id = {p(accountId)} 51 | | UNION ALL 52 | |SELECT event_time, balance 53 | | FROM withdraws 54 | | WHERE account_id = {p(accountId)}"); 55 | 56 | query.Sql.Should().Be( 57 | @"SELECT event_time, balance 58 | FROM charges 59 | WHERE account_id = @account_id 60 | UNION ALL 61 | SELECT event_time, balance 62 | FROM withdraws 63 | WHERE account_id = @account_id"); 64 | 65 | var parameters = query.Parameters.ToList(); 66 | parameters.Count.Should().Be(1); 67 | 68 | parameters[0].Name.Should().Be("account_id"); 69 | parameters[0].Value().Should().Be(accountId.Item1); 70 | } 71 | 72 | [Test] 73 | public void SqlQuery_ItShouldConcatQueries() 74 | { 75 | var accountId = (777, "account_id"); 76 | var toDate = DateTime.Now.Date; 77 | var fromDate = toDate - TimeSpan.FromDays(30); 78 | 79 | var charges = sql(() => $@" 80 | |SELECT event_time, balance 81 | | FROM charges 82 | | WHERE account_id = {p(accountId)} 83 | | AND event_time BETWEEN {p(fromDate)} AND {p(toDate)}"); 84 | 85 | var withdraws = sql(() => $@" 86 | |SELECT event_time, balance 87 | | FROM withdraws 88 | | WHERE account_id = {p(accountId)} 89 | | AND event_time BETWEEN {p(fromDate)} AND {p(toDate)}"); 90 | 91 | var query = charges + sql(() => "| UNION ALL") + withdraws; 92 | 93 | query.Sql.Should().Be( 94 | @"SELECT event_time, balance 95 | FROM charges 96 | WHERE account_id = @account_id 97 | AND event_time BETWEEN @p_1_ AND @p_2_ 98 | UNION ALL 99 | SELECT event_time, balance 100 | FROM withdraws 101 | WHERE account_id = @account_id 102 | AND event_time BETWEEN @p_3_ AND @p_4_"); 103 | 104 | var parameters = query.Parameters.OrderBy(_ => _.Name).ToList(); 105 | parameters.Count.Should().Be(5); 106 | 107 | parameters[0].Name.Should().Be("account_id"); 108 | parameters[0].Value().Should().Be(accountId.Item1); 109 | parameters[1].Name.Should().Be("p_1_"); 110 | parameters[1].Value().Should().Be(fromDate); 111 | parameters[2].Name.Should().Be("p_2_"); 112 | parameters[2].Value().Should().Be(toDate); 113 | parameters[3].Name.Should().Be("p_3_"); 114 | parameters[3].Value().Should().Be(fromDate); 115 | parameters[4].Name.Should().Be("p_4_"); 116 | parameters[4].Value().Should().Be(toDate); 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /FunnyDB/SqlQuery.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace FunnyDB 6 | { 7 | /// 8 | /// Represents an SQL query with it text and parameters. 9 | /// 10 | public class SqlQuery 11 | { 12 | private readonly string _sql; 13 | private readonly SqlQueryParameter[] _parameters; 14 | 15 | /// 16 | /// Initializes a new instance of SQL query. 17 | /// 18 | /// An SQL query text. 19 | /// A collection of this SQL query parameters. 20 | public SqlQuery(string sql, IEnumerable parameters) 21 | { 22 | _sql = sql; 23 | _parameters = parameters == null ? Array.Empty() : parameters.ToArray(); 24 | } 25 | 26 | /// 27 | /// A text of this SQL query. 28 | /// 29 | public string Sql => _sql; 30 | 31 | /// 32 | /// A collection of parameters of this SQL query. 33 | /// 34 | public IReadOnlyCollection Parameters => _parameters; 35 | 36 | /// 37 | /// Represents this SQL query as string. 38 | /// 39 | public override string ToString() 40 | { 41 | return _sql; 42 | } 43 | 44 | /// 45 | /// Concatenates SQL queries with line break. 46 | /// For example, let: 47 | /// q1: 48 | /// SELECT id FROM A 49 | /// WHERE 1 = 1 50 | /// q2: 51 | /// AND id >= 1000 52 | /// Then: 53 | /// q1 + q2: 54 | /// SELECT id FROM A 55 | /// WHERE 1 = 1 56 | /// AND id >= 1000 57 | /// 58 | /// A first query to concatenation. 59 | /// A second query to concatenation 60 | /// Returns a new SQL query as concatenation result of two. 61 | public static SqlQuery operator +(SqlQuery q1, SqlQuery q2) => Concat(q1, q2, "\r\n"); 62 | 63 | /// 64 | /// Concatenates SQL queries without line break. 65 | /// For example, let: 66 | /// q1: 67 | /// SELECT id FROM A 68 | /// WHERE 1 = 1 69 | /// q2: 70 | /// AND id >= 1000 71 | /// Then: 72 | /// q1 / q2: 73 | /// SELECT id FROM A 74 | /// WHERE 1 = 1 AND id >= 1000 75 | /// 76 | /// A first query to concatenation. 77 | /// A second query to concatenation 78 | /// Returns a new SQL query as concatenation result of two. 79 | public static SqlQuery operator /(SqlQuery q1, SqlQuery q2) => Concat(q1, q2, ""); 80 | 81 | private static SqlQuery Concat(SqlQuery q1, SqlQuery q2, string delimiter) 82 | { 83 | var generatedParameters = q1._parameters 84 | .Where(_ => _.Index != SqlQueryParameter.PinnedNameIndex) 85 | .OrderByDescending(_ => _.Index); 86 | 87 | var (q2Sql, q2Params) = ReindexParameters(generatedParameters.Count(), q2._sql, q2._parameters); 88 | var sql = q1._sql + delimiter + q2Sql; 89 | var parameters = new List(q1._parameters); 90 | parameters.AddRange(q2Params); 91 | 92 | return new SqlQuery(sql, parameters); 93 | } 94 | 95 | private static (string, SqlQueryParameter[]) ReindexParameters( 96 | int startIndex, 97 | string sql, 98 | SqlQueryParameter[] parameters) 99 | { 100 | var newSql = sql; 101 | var newParameters = new List(); 102 | var generatedParameters = parameters 103 | .Where(_ => _.Index != SqlQueryParameter.PinnedNameIndex) 104 | .OrderByDescending(_ => _.Index); 105 | 106 | foreach (var parameter in generatedParameters) 107 | { 108 | var newParameter = parameter.ChangeIndex(startIndex + parameter.Index); 109 | newSql = newSql.Replace(parameter.Name, newParameter.Name); 110 | newParameters.Add(newParameter); 111 | } 112 | 113 | return (newSql, newParameters.ToArray()); 114 | } 115 | } 116 | } -------------------------------------------------------------------------------- /FunnyDB/SqlQueryValue.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data; 3 | 4 | namespace FunnyDB 5 | { 6 | public sealed class SqlQueryValue 7 | { 8 | public readonly DbType DbType; 9 | public readonly Func Value; 10 | 11 | private SqlQueryValue(DbType dbType, Func value) 12 | { 13 | DbType = dbType; 14 | Value = value; 15 | } 16 | 17 | public static implicit operator SqlQueryValue(byte value) => 18 | new SqlQueryValue(DbType.Byte, () => value); 19 | 20 | public static implicit operator SqlQueryValue(byte? value) => 21 | new SqlQueryValue(DbType.Byte, () => value); 22 | 23 | public static implicit operator SqlQueryValue(int value) => 24 | new SqlQueryValue(DbType.Int32, () => value); 25 | 26 | public static implicit operator SqlQueryValue(int? value) => 27 | new SqlQueryValue(DbType.Int32, () => value); 28 | 29 | public static implicit operator SqlQueryValue(long value) => 30 | new SqlQueryValue(DbType.Int64, () => value); 31 | 32 | public static implicit operator SqlQueryValue(long? value) => 33 | new SqlQueryValue(DbType.Int64, () => value); 34 | 35 | public static implicit operator SqlQueryValue(float value) => 36 | new SqlQueryValue(DbType.Single, () => value); 37 | 38 | public static implicit operator SqlQueryValue(float? value) => 39 | new SqlQueryValue(DbType.Single, () => value); 40 | 41 | public static implicit operator SqlQueryValue(double value) => 42 | new SqlQueryValue(DbType.Double, () => value); 43 | 44 | public static implicit operator SqlQueryValue(double? value) => 45 | new SqlQueryValue(DbType.Double, () => value); 46 | 47 | public static implicit operator SqlQueryValue(string value) => 48 | new SqlQueryValue(DbType.String, () => value); 49 | 50 | public static implicit operator SqlQueryValue(DateTime value) => 51 | new SqlQueryValue(DbType.DateTime, () => value); 52 | 53 | public static implicit operator SqlQueryValue(DateTime? value) => 54 | new SqlQueryValue(DbType.DateTime, () => value); 55 | 56 | public static implicit operator SqlQueryValue(bool value) => 57 | new SqlQueryValue(DbType.Boolean, () => value); 58 | 59 | public static implicit operator SqlQueryValue(bool? value) => 60 | new SqlQueryValue(DbType.Boolean, () => value); 61 | 62 | public static implicit operator SqlQueryValue(Guid value) => 63 | new SqlQueryValue(DbType.Guid, () => value); 64 | 65 | public static implicit operator SqlQueryValue(Guid? value) => 66 | new SqlQueryValue(DbType.Guid, () => value); 67 | 68 | public static implicit operator SqlQueryValue(Func value) => 69 | new SqlQueryValue(DbType.Byte, () => value()); 70 | 71 | public static implicit operator SqlQueryValue(Func value) => 72 | new SqlQueryValue(DbType.Byte, () => value()); 73 | 74 | public static implicit operator SqlQueryValue(Func value) => 75 | new SqlQueryValue(DbType.Int32, () => value()); 76 | 77 | public static implicit operator SqlQueryValue(Func value) => 78 | new SqlQueryValue(DbType.Int32, () => value()); 79 | 80 | public static implicit operator SqlQueryValue(Func value) => 81 | new SqlQueryValue(DbType.Int64, () => value()); 82 | 83 | public static implicit operator SqlQueryValue(Func value) => 84 | new SqlQueryValue(DbType.Int64, () => value()); 85 | 86 | public static implicit operator SqlQueryValue(Func value) => 87 | new SqlQueryValue(DbType.Single, () => value()); 88 | 89 | public static implicit operator SqlQueryValue(Func value) => 90 | new SqlQueryValue(DbType.Single, () => value()); 91 | 92 | public static implicit operator SqlQueryValue(Func value) => 93 | new SqlQueryValue(DbType.Double, () => value()); 94 | 95 | public static implicit operator SqlQueryValue(Func value) => 96 | new SqlQueryValue(DbType.Double, () => value()); 97 | 98 | public static implicit operator SqlQueryValue(Func value) => 99 | new SqlQueryValue(DbType.String, value); 100 | 101 | public static implicit operator SqlQueryValue(Func value) => 102 | new SqlQueryValue(DbType.DateTime, () => value()); 103 | 104 | public static implicit operator SqlQueryValue(Func value) => 105 | new SqlQueryValue(DbType.DateTime, () => value()); 106 | 107 | public static implicit operator SqlQueryValue(Func value) => 108 | new SqlQueryValue(DbType.Boolean, () => value()); 109 | 110 | public static implicit operator SqlQueryValue(Func value) => 111 | new SqlQueryValue(DbType.Boolean, () => value()); 112 | 113 | public static implicit operator SqlQueryValue(Func value) => 114 | new SqlQueryValue(DbType.Guid, () => value()); 115 | 116 | public static implicit operator SqlQueryValue(Func value) => 117 | new SqlQueryValue(DbType.Guid, () => value()); 118 | } 119 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | FunnyDB - a simple and lightweight query builder and object mapper for .Net 2 | =========================================================================== 3 | 4 | FunnyDB was inspired by [ScalikeJDBC](http://scalikejdbc.org/). 5 | 6 | It was developed especially for programmers who likes plain SQL like me. 7 | 8 | FunnyDB uses power of string interpolation and solves huge problem - 9 | gap between parameter definition and it value assigment. 10 | 11 | Packages 12 | -------- 13 | 14 | NuGet releases: https://www.nuget.org/packages/FunnyDB/ 15 | 16 | First example 17 | ------------- 18 | 19 | In code above we query accounts 20 | 21 | ```csharp 22 | // Import necessary directives like: sql and p 23 | using static FunnyDB.Dialect; 24 | 25 | // Create connection 26 | using var cn = new NpgsqlConnection(connectionString); 27 | 28 | // Define parameters values they are passed via method paramater usually 29 | var minBalance = 50; 30 | var maxBalance = 200; 31 | 32 | // Create query, execute it and map results 33 | // To bind minBalance and maxBalance use p(...) directive 34 | var accounts = sql(() => $@" 35 | |SELECT * 36 | | FROM accounts 37 | | WHERE sms_number IS NOT NULL 38 | | AND balance BETWEEN {p(minBalance)} AND {p(maxBalance)} 39 | | ORDER BY name;" 40 | ).Execute(cn).Map(Account.Produce).ToList(); 41 | 42 | class Account 43 | { 44 | public static Account Produce(ISqlResultSet rs) => new Account( 45 | rs.Long("id"), 46 | rs.String("name"), 47 | rs.DateTime("updated_at"), 48 | rs.Long("balance"), 49 | rs.String("sms_number", null) 50 | ); 51 | 52 | private Account(long id, string name, DateTime updatedAt, long balance, string smsNumber) 53 | { 54 | Id = id; 55 | Name = name; 56 | UpdatedAt = updatedAt; 57 | Balance = balance; 58 | SmsNumber = smsNumber; 59 | } 60 | 61 | public readonly long Id; 62 | public readonly string Name; 63 | public readonly DateTime UpdatedAt; 64 | public readonly long Balance; 65 | public readonly string SmsNumber; 66 | } 67 | ``` 68 | 69 | Caveats 70 | ------- 71 | 72 | FunnyDB protects you from SQL injection with `{p(value)}` syntax. But it can't protect you 73 | from mistakes when you use `{value}` instead of `{p(value)}`. Humans are not machines and 74 | this kind of errors is a matter of time. To solve this issue FunnyDB provides SqlQueryLinter 75 | which can be executed as a step on CI side or from you unit test (see example [here](FunnyDB.Test/SqlLinterTests.cs)). 76 | 77 | 78 | Compose queries 79 | --------------- 80 | 81 | You can compose you queries with '+' or '/' operators. 82 | Operator '+' concatenates two queries with line break between queries instead of '/' operator. 83 | 84 | ```csharp 85 | var charges = sql(() => $@" 86 | |SELECT event_time, balance 87 | | FROM charges 88 | | WHERE account_id = {p(accountId)} 89 | | AND event_time BETWEEN {p(fromDate)} AND {p(toDate)}"); 90 | 91 | var withdraws = sql(() => $@" 92 | |SELECT event_time, balance 93 | | FROM withdraws 94 | | WHERE account_id = {p(accountId)} 95 | | AND event_time BETWEEN {p(fromDate)} AND {p(toDate)}"); 96 | 97 | var query = charges + sql(() => " UNION ALL") + withdraws; 98 | 99 | Console.WriteLine(query.Sql); 100 | ``` 101 | 102 | A result of this code execution is follow SQL code: 103 | 104 | ```sql 105 | SELECT event_time, balance 106 | FROM charges 107 | WHERE account_id = @account_id 108 | AND event_time BETWEEN @p_1_ AND @p_2_ 109 | UNION ALL 110 | SELECT event_time, balance 111 | FROM withdraws 112 | WHERE account_id = @account_id 113 | AND event_time BETWEEN @p_3_ AND @p_4_ 114 | ``` 115 | 116 | Transactions 117 | ------------ 118 | 119 | ### Tx block 120 | 121 | Executes query / update in block-scoped transactions. 122 | 123 | In the end of scope transaction will be committed. 124 | 125 | If exception will happen in transaction scope rollback will performed. 126 | 127 | ```csharp 128 | // Sessions are depenend from database drivers implementation 129 | using FunnyDB.Postgres; 130 | 131 | Session.Tx(connectionString, session => 132 | { 133 | // --- Transcation scope start --- 134 | sql(() => $"INSERT INTO account (name, balance) VALUES ({p(name1)}, {p(balance1)}, null)".ExecuteNonQuery(session); 135 | sql(() => $"INSERT INTO account (name, balance) VALUES ({p(name2)}, {p(balance2)}, null)".ExecuteNonQuery(session); 136 | // --- Transaction scope end --- 137 | } 138 | ``` 139 | 140 | ### AutoCommit block 141 | 142 | Executes query / update in auto-commit mode. 143 | 144 | When using AutoCommit session, every operation will be executed in auto-commit mode. 145 | 146 | ```csharp 147 | Session.AutoCommit(connectionString, session => 148 | { 149 | sql(() => $"INSERT INTO account (name, balance) VALUES ({p(name1)}, {p(balance1)}, null)".ExecuteNonQuery(session); // auto-commit 150 | sql(() => $"INSERT INTO account (name, balance) VALUES ({p(name2)}, {p(balance2)}, null)".ExecuteNonQuery(session); // auto-commit 151 | } 152 | ``` 153 | 154 | Map domain model values to FunnyDB parameters 155 | --------------------------------------------- 156 | 157 | FunnyDB uses parameter types restrictions to prevent unexpectedly not supported type assignments. 158 | 159 | Follow code will fail on compilation: 160 | 161 | ```csharp 162 | public class AccountId 163 | { 164 | public AccountId(long value) 165 | { 166 | Value = value; 167 | } 168 | 169 | public readonly long Value; 170 | } 171 | 172 | var accountId = new AccountId(1); 173 | var query = sql(() => $"SELECT balance FROM accounts WHERE id = {p(accountId)}"); 174 | ``` 175 | 176 | To fix this issue we can use p(accountId.Value) of course, but it little bit verbose and annoying. 177 | 178 | As alternative we can write mapping between domain and database model: 179 | 180 | ```csharp 181 | public static class Dialect 182 | { 183 | public static string p(AccountId accountId) => FunnyDB.Dialect.p(accountId.Value); 184 | } 185 | ``` 186 | 187 | And use it in our query: 188 | ```csharp 189 | // Import defined domain model dialect directives 190 | using static Dialect; 191 | 192 | // Now query compiles and works well 193 | var accountId = new AccountId(1); 194 | sql(() => $"SELECT balance FROM accounts WHERE id = {p(accountId)}"); 195 | ``` 196 | -------------------------------------------------------------------------------- /FunnyDB/Dialect.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace FunnyDB 6 | { 7 | /// 8 | /// Provides a set of methods which allows to build parametrised plain SQL query. 9 | /// 10 | public static class Dialect 11 | { 12 | [ThreadStatic] private static Dictionary _queryParameters; 13 | [ThreadStatic] private static StringBuilder _parameterNameBuilder; 14 | 15 | public static int NextParameterIndex => _queryParameters.Count; 16 | 17 | // ReSharper disable once InconsistentNaming 18 | public static SqlQuery sql(Func query, char? margin = '|') 19 | { 20 | var parameters = _queryParameters; 21 | if (parameters == null) 22 | { 23 | parameters = new Dictionary(); 24 | _queryParameters = parameters; 25 | } 26 | 27 | try 28 | { 29 | var body = query(); 30 | if (margin.HasValue) 31 | { 32 | body = Strip(body, margin.Value); 33 | } 34 | 35 | return new SqlQuery(body, parameters.Values); 36 | } 37 | finally 38 | { 39 | parameters.Clear(); 40 | } 41 | } 42 | 43 | /// 44 | /// Returns specified string itself. Used as linter suppressor. 45 | /// 46 | // ReSharper disable once InconsistentNaming 47 | public static string s(string str) => str; 48 | 49 | // ReSharper disable once InconsistentNaming 50 | public static string p((SqlQueryValue, string) namedValue) 51 | { 52 | var (value, name) = namedValue; 53 | var parameter = new DbSqlQueryParameter(name, value.DbType, () => value.Value()); 54 | return p(parameter); 55 | } 56 | 57 | // ReSharper disable once InconsistentNaming 58 | public static string p(IReadOnlyCollection values) 59 | { 60 | var sb = _parameterNameBuilder; 61 | if (sb == null) 62 | { 63 | sb = new StringBuilder(); 64 | _parameterNameBuilder = sb; 65 | } 66 | 67 | try 68 | { 69 | var delimiter = ""; 70 | foreach (var v in values) 71 | { 72 | sb.Append(delimiter); 73 | sb.Append(p(v)); 74 | delimiter = ", "; 75 | } 76 | 77 | return sb.ToString(); 78 | } 79 | finally 80 | { 81 | sb.Clear(); 82 | } 83 | } 84 | 85 | 86 | // ReSharper disable once InconsistentNaming 87 | public static string p(SqlQueryValue value, params SqlQueryValue[] values) 88 | { 89 | var sb = _parameterNameBuilder; 90 | if (sb == null) 91 | { 92 | sb = new StringBuilder(); 93 | _parameterNameBuilder = sb; 94 | } 95 | 96 | try 97 | { 98 | sb.Append(p(value)); 99 | 100 | foreach (var vn in values) 101 | { 102 | sb.Append(", "); 103 | sb.Append(p(vn)); 104 | } 105 | 106 | return sb.ToString(); 107 | } 108 | finally 109 | { 110 | sb.Clear(); 111 | } 112 | } 113 | 114 | // ReSharper disable once InconsistentNaming 115 | public static string p(SqlQueryValue value) 116 | { 117 | var index = NextParameterIndex; 118 | var parameter = new DbSqlQueryParameter(index, value.DbType, value.Value); 119 | return p(parameter); 120 | } 121 | 122 | // ReSharper disable once InconsistentNaming 123 | public static string p(SqlQueryParameter parameter, params SqlQueryParameter[] parameters) 124 | { 125 | var sb = _parameterNameBuilder; 126 | if (sb == null) 127 | { 128 | sb = new StringBuilder(); 129 | _parameterNameBuilder = sb; 130 | } 131 | 132 | try 133 | { 134 | sb.Append(p(parameter)); 135 | 136 | foreach (var pn in parameters) 137 | { 138 | sb.Append(", "); 139 | sb.Append(p(pn)); 140 | } 141 | 142 | return sb.ToString(); 143 | } 144 | finally 145 | { 146 | sb.Clear(); 147 | } 148 | } 149 | 150 | // ReSharper disable once InconsistentNaming 151 | public static string p(SqlQueryParameter parameter) 152 | { 153 | var parameters = _queryParameters; 154 | var parameterName = parameter.Name; 155 | if (parameter.Index == SqlQueryParameter.PinnedNameIndex 156 | && parameters.TryGetValue(parameterName, out var existParameter)) 157 | { 158 | if (Equals(existParameter.Value(), parameter.Value())) 159 | { 160 | return GetParameterNameForQuery(parameterName); 161 | } 162 | 163 | throw new InvalidOperationException( 164 | "Named parameter unexpectedly used with different values (" + 165 | $"parameter_name={parameterName}," + 166 | $"expected_value={existParameter.Value}," + 167 | $"actual_value={parameter.Value})"); 168 | } 169 | 170 | parameters.Add(parameterName, parameter); 171 | return GetParameterNameForQuery(parameterName); 172 | } 173 | 174 | private static string GetParameterNameForQuery(string parameterName) 175 | { 176 | return parameterName.StartsWith("@") ? parameterName : "@" + parameterName; 177 | } 178 | 179 | private static string Strip(string text, char margin) 180 | { 181 | var sb = new StringBuilder(); 182 | var skip = true; 183 | foreach (var ch in text) 184 | { 185 | if (char.IsWhiteSpace(ch) && skip) 186 | { 187 | continue; 188 | } 189 | 190 | if (ch == margin && skip) 191 | { 192 | skip = false; 193 | continue; 194 | } 195 | 196 | sb.Append(ch); 197 | skip = ch == '\n'; 198 | } 199 | 200 | return sb.ToString(); 201 | } 202 | } 203 | } -------------------------------------------------------------------------------- /FunnyDB/SqlQueryResult.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Data; 4 | 5 | namespace FunnyDB 6 | { 7 | public sealed class SqlQueryResult : ISqlResultSet 8 | { 9 | private readonly IDataReader _reader; 10 | 11 | public SqlQueryResult(IDataReader reader) 12 | { 13 | _reader = reader; 14 | } 15 | 16 | public IEnumerable Map(Func f) 17 | { 18 | while (_reader.Read()) 19 | { 20 | yield return f(this); 21 | } 22 | 23 | _reader.Close(); 24 | } 25 | 26 | public IDataReader AsDataReader() => _reader; 27 | 28 | int ISqlResultSet.Int(string name) 29 | { 30 | return Get(name, _reader.GetInt32); 31 | } 32 | 33 | int ISqlResultSet.Int(string name, int defaultValue) 34 | { 35 | return Get(name, _reader.GetInt32, defaultValue); 36 | } 37 | 38 | int ISqlResultSet.Int(int ordinal) 39 | { 40 | return _reader.GetInt32(ordinal); 41 | } 42 | 43 | int ISqlResultSet.Int(int ordinal, int defaultValue) 44 | { 45 | return Get(ordinal, _reader.GetInt32, defaultValue); 46 | } 47 | 48 | long ISqlResultSet.Long(string name) 49 | { 50 | return Get(name, _reader.GetInt64); 51 | } 52 | 53 | long ISqlResultSet.Long(string name, long defaultValue) 54 | { 55 | return Get(name, _reader.GetInt64, defaultValue); 56 | } 57 | 58 | long ISqlResultSet.Long(int ordinal) 59 | { 60 | return _reader.GetInt64(ordinal); 61 | } 62 | 63 | long ISqlResultSet.Long(int ordinal, long defaultValue) 64 | { 65 | return Get(ordinal, _reader.GetInt64, defaultValue); 66 | } 67 | 68 | float ISqlResultSet.Float(string name) 69 | { 70 | return Get(name, _reader.GetFloat); 71 | } 72 | 73 | float ISqlResultSet.Float(string name, float defaultValue) 74 | { 75 | return Get(name, _reader.GetFloat, defaultValue); 76 | } 77 | 78 | float ISqlResultSet.Float(int ordinal) 79 | { 80 | return _reader.GetFloat(ordinal); 81 | } 82 | 83 | float ISqlResultSet.Float(int ordinal, float defaultValue) 84 | { 85 | return Get(ordinal, _reader.GetFloat, defaultValue); 86 | } 87 | 88 | double ISqlResultSet.Double(string name) 89 | { 90 | return Get(name, _reader.GetDouble); 91 | } 92 | 93 | double ISqlResultSet.Double(string name, double defaultValue) 94 | { 95 | return Get(name, _reader.GetDouble, defaultValue); 96 | } 97 | 98 | double ISqlResultSet.Double(int ordinal) 99 | { 100 | return _reader.GetDouble(ordinal); 101 | } 102 | 103 | double ISqlResultSet.Double(int ordinal, double defaultValue) 104 | { 105 | return Get(ordinal, _reader.GetDouble, defaultValue); 106 | } 107 | 108 | decimal ISqlResultSet.Decimal(string name) 109 | { 110 | return Get(name, _reader.GetDecimal); 111 | } 112 | 113 | decimal ISqlResultSet.Decimal(string name, decimal defaultValue) 114 | { 115 | return Get(name, _reader.GetDecimal, defaultValue); 116 | } 117 | 118 | decimal ISqlResultSet.Decimal(int ordinal) 119 | { 120 | return _reader.GetDecimal(ordinal); 121 | } 122 | 123 | decimal ISqlResultSet.Decimal(int ordinal, decimal defaultValue) 124 | { 125 | return Get(ordinal, _reader.GetDecimal, defaultValue); 126 | } 127 | 128 | string ISqlResultSet.String(string name) 129 | { 130 | return Get(name, _reader.GetString); 131 | } 132 | 133 | string ISqlResultSet.String(string name, string defaultValue) 134 | { 135 | return Get(name, _reader.GetString, defaultValue); 136 | } 137 | 138 | string ISqlResultSet.String(int ordinal) 139 | { 140 | return _reader.GetString(ordinal); 141 | } 142 | 143 | string ISqlResultSet.String(int ordinal, string defaultValue) 144 | { 145 | return Get(ordinal, _reader.GetString, defaultValue); 146 | } 147 | 148 | bool ISqlResultSet.Bool(string name) 149 | { 150 | return Get(name, _reader.GetBoolean); 151 | } 152 | 153 | bool ISqlResultSet.Bool(string name, bool defaultValue) 154 | { 155 | return Get(name, _reader.GetBoolean, defaultValue); 156 | } 157 | 158 | bool ISqlResultSet.Bool(int ordinal) 159 | { 160 | return _reader.GetBoolean(ordinal); 161 | } 162 | 163 | bool ISqlResultSet.Bool(int ordinal, bool defaultValue) 164 | { 165 | return Get(ordinal, _reader.GetBoolean, defaultValue); 166 | } 167 | 168 | DateTime ISqlResultSet.DateTime(string name) 169 | { 170 | return Get(name, _reader.GetDateTime); 171 | } 172 | 173 | DateTime ISqlResultSet.DateTime(string name, DateTime defaultValue) 174 | { 175 | return Get(name, _reader.GetDateTime, defaultValue); 176 | } 177 | 178 | DateTime ISqlResultSet.DateTime(int ordinal) 179 | { 180 | return _reader.GetDateTime(ordinal); 181 | } 182 | 183 | DateTime ISqlResultSet.DateTime(int ordinal, DateTime defaultValue) 184 | { 185 | return Get(ordinal, _reader.GetDateTime, defaultValue); 186 | } 187 | 188 | Guid ISqlResultSet.Guid(string name) 189 | { 190 | return Get(name, _reader.GetGuid); 191 | } 192 | 193 | Guid ISqlResultSet.Guid(string name, Guid defaultValue) 194 | { 195 | return Get(name, _reader.GetGuid, defaultValue); 196 | } 197 | 198 | Guid ISqlResultSet.Guid(int ordinal) 199 | { 200 | return _reader.GetGuid(ordinal); 201 | } 202 | 203 | Guid ISqlResultSet.Guid(int ordinal, Guid defaultValue) 204 | { 205 | return Get(ordinal, _reader.GetGuid, defaultValue); 206 | } 207 | 208 | private T Get(string name, Func f) 209 | { 210 | var ordinal = _reader.GetOrdinal(name); 211 | return f(ordinal); 212 | } 213 | 214 | private T Get(string name, Func f, T defaultValue) 215 | { 216 | var ordinal = _reader.GetOrdinal(name); 217 | return Get(ordinal, f, defaultValue); 218 | } 219 | 220 | private T Get(int ordinal, Func f, T defaultValue) 221 | { 222 | return _reader.IsDBNull(ordinal) ? defaultValue : f(ordinal); 223 | } 224 | } 225 | } -------------------------------------------------------------------------------- /FunnyDB.Test/SqlIntegrationTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using FluentAssertions; 4 | using FunnyDB.Postgres; 5 | using Npgsql; 6 | using NUnit.Framework; 7 | using static FunnyDB.Dialect; 8 | 9 | namespace FunnyDB.Test 10 | { 11 | [TestFixture] 12 | public class SqlIntegrationTest 13 | { 14 | private static readonly DateTime Now = new DateTime(2020, 12, 07, 23, 17, 00); 15 | 16 | [Test] 17 | public void SqlQuery_WithoutParameters_ItShouldReadDataFromDatabase() 18 | { 19 | DbTest(connectionString => 20 | { 21 | using var cn = new NpgsqlConnection(connectionString); 22 | 23 | var accounts = sql(() => @" 24 | |SELECT * 25 | | FROM accounts 26 | | ORDER BY name;" 27 | ).Execute(cn).Map(Account.Produce).ToList(); 28 | 29 | accounts.Count.Should().Be(5); 30 | 31 | accounts[0].Name.Should().Be("a"); 32 | accounts[0].Balance.Should().Be(100); 33 | accounts[0].UpdatedAt.Should().Be(new DateTime(2020, 12, 06, 23, 17, 00)); 34 | accounts[0].SmsNumber.Should().Be("+79267770001"); 35 | 36 | accounts[1].Name.Should().Be("b"); 37 | accounts[1].Balance.Should().Be(1000); 38 | accounts[1].UpdatedAt.Should().Be(new DateTime(2020, 12, 05, 23, 17, 00)); 39 | accounts[1].SmsNumber.Should().BeNull(); 40 | 41 | accounts[2].Name.Should().Be("c"); 42 | accounts[2].Balance.Should().Be(0); 43 | accounts[2].UpdatedAt.Should().Be(new DateTime(2020, 12, 07, 11, 17, 00)); 44 | accounts[2].SmsNumber.Should().BeNull(); 45 | }); 46 | } 47 | 48 | [Test] 49 | public void SqlQuery_WithParameters_ItShouldReadDataFromDatabase() 50 | { 51 | DbTest(connectionString => 52 | { 53 | using var cn = new NpgsqlConnection(connectionString); 54 | 55 | var minBalance = 50; 56 | var maxBalance = 200; 57 | var accounts = sql(() => $@" 58 | |SELECT * 59 | | FROM accounts 60 | | WHERE sms_number IS NOT NULL 61 | | AND balance BETWEEN {p(minBalance)} AND {p(maxBalance)} 62 | | ORDER BY name;" 63 | ).Execute(cn).Map(Account.Produce).ToList(); 64 | 65 | accounts.Count.Should().Be(1); 66 | 67 | accounts[0].Name.Should().Be("a"); 68 | accounts[0].Balance.Should().Be(100); 69 | accounts[0].UpdatedAt.Should().Be(new DateTime(2020, 12, 06, 23, 17, 00)); 70 | accounts[0].SmsNumber.Should().Be("+79267770001"); 71 | }); 72 | } 73 | 74 | [Test] 75 | public void SqlQuery_ItShouldUpdateDataInDatabase() 76 | { 77 | DbTest(connectionString => 78 | { 79 | using var cn = new NpgsqlConnection(connectionString); 80 | 81 | var accountName = "d"; 82 | 83 | var account = sql(() => $@" 84 | |SELECT * 85 | | FROM accounts 86 | | WHERE name = {p(accountName)};" 87 | ).Execute(cn).Map(Account.Produce).Single(); 88 | 89 | var now = DateTime.UtcNow; 90 | var newBalance = account.Balance + Math.Abs(account.Balance) * 3; 91 | 92 | sql(() => $@" 93 | |UPDATE accounts 94 | | SET balance = {p(newBalance)}, 95 | | updated_at = {p(now)} 96 | | WHERE id = {p(account.Id)}" 97 | ).ExecuteNonQuery(cn); 98 | 99 | var updatedAccount = sql(() => $@" 100 | |SELECT * 101 | | FROM accounts 102 | | WHERE name = {p(accountName)};" 103 | ).Execute(cn).Map(Account.Produce).Single(); 104 | 105 | updatedAccount.Id.Should().Be(account.Id); 106 | updatedAccount.Balance.Should().Be(newBalance); 107 | updatedAccount.UpdatedAt.Should().BeCloseTo(now, TimeSpan.FromMilliseconds(100)); 108 | }); 109 | } 110 | 111 | [Test] 112 | public void SqlQuery_ItShouldRollbackTransactionOnError() 113 | { 114 | DbTest(connectionString => 115 | { 116 | var accountName = "d"; 117 | Account account; 118 | using var cn = new NpgsqlConnection(connectionString); 119 | { 120 | account = sql(() => $@" 121 | |SELECT * 122 | | FROM accounts 123 | | WHERE name = {p(accountName)};" 124 | ).Execute(cn).Map(Account.Produce).Single(); 125 | } 126 | 127 | try 128 | { 129 | Session.Tx(connectionString, session => 130 | { 131 | var now = DateTime.UtcNow; 132 | var newBalance = account.Balance + Math.Abs(account.Balance) * 3; 133 | 134 | sql(() => $@" 135 | |UPDATE accounts 136 | | SET balance = {p(newBalance)}, 137 | | updated_at = {p(now)} 138 | | WHERE id = {p(account.Id)}" 139 | ).ExecuteNonQuery(session); 140 | 141 | throw new InvalidOperationException("Something bad happen"); 142 | }); 143 | } 144 | catch (Exception) 145 | { 146 | // ignore 147 | } 148 | 149 | var updatedAccount = sql(() => $@" 150 | |SELECT * 151 | | FROM accounts 152 | | WHERE name = {p(accountName)};" 153 | ).Execute(cn).Map(Account.Produce).Single(); 154 | 155 | updatedAccount.Id.Should().Be(account.Id); 156 | updatedAccount.Balance.Should().Be(account.Balance); 157 | updatedAccount.UpdatedAt.Should().Be(account.UpdatedAt); 158 | }); 159 | } 160 | 161 | [Test] 162 | public void SqlQuery_ItShouldPerformAutoCommitOnEachSentence() 163 | { 164 | DbTest(connectionString => 165 | { 166 | var accountName = "d"; 167 | Account account; 168 | using var cn = new NpgsqlConnection(connectionString); 169 | { 170 | account = sql(() => $@" 171 | |SELECT * 172 | | FROM accounts 173 | | WHERE name = {p(accountName)};" 174 | ).Execute(cn).Map(Account.Produce).Single(); 175 | } 176 | 177 | try 178 | { 179 | Session.AutoCommit(connectionString, session => 180 | { 181 | for (var i = 0; i < 5; i++) 182 | { 183 | if (i == 4) 184 | { 185 | throw new InvalidOperationException("Something bad happen"); 186 | } 187 | 188 | sql(() => $@" 189 | UPDATE accounts SET balance = balance + 100 WHERE id = {p(account.Id)}" 190 | ).ExecuteNonQuery(session); 191 | } 192 | }); 193 | } 194 | catch (Exception) 195 | { 196 | // ignore 197 | } 198 | 199 | var updatedAccount = sql(() => $@" 200 | |SELECT * 201 | | FROM accounts 202 | | WHERE name = {p(accountName)};" 203 | ).Execute(cn).Map(Account.Produce).Single(); 204 | 205 | updatedAccount.Id.Should().Be(account.Id); 206 | updatedAccount.Balance.Should().Be(account.Balance + 400); 207 | updatedAccount.UpdatedAt.Should().Be(account.UpdatedAt); 208 | }); 209 | } 210 | 211 | private static void DbTest(Action f) 212 | { 213 | if (!GetConnectionString(out var connectionString)) 214 | { 215 | Assert.Ignore("Connection string is not set."); 216 | return; 217 | } 218 | 219 | PolluteDb(connectionString); 220 | f(connectionString); 221 | } 222 | 223 | private static void PolluteDb(string connectionString) 224 | { 225 | Session.Tx(connectionString, session => 226 | { 227 | sql(() => @" 228 | |CREATE TABLE IF NOT EXISTS accounts ( 229 | | id SERIAL PRIMARY KEY, 230 | | name TEXT NOT NULL, 231 | | updated_at TIMESTAMP NOT NULL, 232 | | balance BIGINT NOT NULL, 233 | | sms_number TEXT NULL 234 | |)" 235 | ).ExecuteNonQuery(session); 236 | 237 | sql(() => "TRUNCATE TABLE accounts;").ExecuteNonQuery(session); 238 | 239 | sql(() => $@" 240 | |INSERT INTO accounts (name, updated_at, balance, sms_number) 241 | |VALUES ('a', {p(Now)} - interval '24 hours', 100, '+79267770001'), 242 | | ('b', {p(Now)} - interval '48 hours', 1000, NULL), 243 | | ('c', {p(Now)} - interval '12 hours', 0, NULL), 244 | | ('d', {p(Now)} - interval '72 hours', -100, NULL), 245 | | ('f', {p(Now)} - interval '10 hours', 0, '+79059990099');" 246 | ).ExecuteNonQuery(session); 247 | }); 248 | } 249 | 250 | private static bool GetConnectionString(out string connectionString) 251 | { 252 | connectionString = Environment.GetEnvironmentVariable("FunnyDb_Test_ConnectionString"); 253 | return !string.IsNullOrWhiteSpace(connectionString); 254 | } 255 | 256 | private class Account 257 | { 258 | public static Account Produce(ISqlResultSet rs) => new Account( 259 | rs.Long("id"), 260 | rs.String("name"), 261 | rs.DateTime("updated_at"), 262 | rs.Long("balance"), 263 | rs.String("sms_number", null) 264 | ); 265 | 266 | private Account(long id, string name, DateTime updatedAt, long balance, string smsNumber) 267 | { 268 | Id = id; 269 | Name = name; 270 | UpdatedAt = updatedAt; 271 | Balance = balance; 272 | SmsNumber = smsNumber; 273 | } 274 | 275 | public readonly long Id; 276 | public readonly string Name; 277 | public readonly DateTime UpdatedAt; 278 | public readonly long Balance; 279 | public readonly string SmsNumber; 280 | } 281 | } 282 | } -------------------------------------------------------------------------------- /FunnyDB/SqlQueryLinter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text; 6 | 7 | namespace FunnyDB 8 | { 9 | public class SqlQueryLinter : IDisposable 10 | { 11 | private static readonly string[] DefaultFileExtensions = new[] {".cs"}; 12 | 13 | private readonly BinaryReader _reader; 14 | private int _line = 1; 15 | private int _position; 16 | 17 | /// 18 | /// Validates files in specified folder. 19 | /// 20 | /// Path to folder which should be validated. 21 | /// A set of collected errors. 22 | /// A collection extensions of files which should be validated. By default 'cs'. 23 | /// Determines is linter should check nested folders recursively. 24 | /// A predicate which returns true if specified file should be ignored (absolute path used). 25 | /// Returns true if folder contains valid files. Otherwise returns false. 26 | public static bool ValidateFolder( 27 | string path, 28 | out IReadOnlyCollection errors, 29 | string[] extensions = null, 30 | bool reqursive = true, 31 | Func ignore = null) 32 | { 33 | if (!Directory.Exists(path)) 34 | { 35 | throw new InvalidOperationException($"Folder not found: {path}"); 36 | } 37 | 38 | var collectedErrors = (List) null; 39 | if (reqursive) 40 | { 41 | foreach (var nestedFolder in Directory.GetDirectories(path)) 42 | { 43 | if (!ValidateFolder( 44 | nestedFolder, 45 | extensions: extensions, 46 | reqursive: true, 47 | ignore: ignore, 48 | errors: out var directoryErrors)) 49 | { 50 | collectedErrors = collectedErrors ?? new List(); 51 | collectedErrors.AddRange(directoryErrors); 52 | } 53 | } 54 | 55 | foreach (var file in Directory.GetFiles(path)) 56 | { 57 | if (ignore != null && ignore(file)) 58 | { 59 | continue; 60 | } 61 | 62 | var fileExt = Path.GetExtension(file); 63 | var targetExt = extensions ?? DefaultFileExtensions; 64 | if (targetExt.All(_ => _ != fileExt)) 65 | { 66 | continue; 67 | } 68 | 69 | using (var f = File.OpenRead(file)) 70 | { 71 | if (!Validate(f, out var fileErrors)) 72 | { 73 | collectedErrors = collectedErrors ?? new List(); 74 | collectedErrors.AddRange(fileErrors.Select(_ => new FileError(file, _))); 75 | } 76 | } 77 | } 78 | } 79 | 80 | errors = (IReadOnlyCollection) collectedErrors ?? Array.Empty(); 81 | return errors.Count == 0; 82 | } 83 | 84 | /// 85 | /// Validates content in specified stream. 86 | /// 87 | /// A code to validate. 88 | /// A collection of errors if found. 89 | /// Returns true if content in stream are correct; otherwise returns false. 90 | public static bool Validate(string code, out IReadOnlyCollection errors) 91 | { 92 | var bytes = Encoding.UTF8.GetBytes(code); 93 | using (var ms = new MemoryStream(bytes)) 94 | { 95 | ms.Seek(0, SeekOrigin.Begin); 96 | return Validate(ms, out errors); 97 | } 98 | } 99 | 100 | /// 101 | /// Validates content in specified stream. 102 | /// 103 | /// A stream with content to validate. 104 | /// A collection of errors if found. 105 | /// Returns true if content in stream are correct; otherwise returns false. 106 | public static bool Validate(Stream stream, out IReadOnlyCollection errors) 107 | { 108 | var linter = new SqlQueryLinter(stream); 109 | return linter.Validate(out errors); 110 | } 111 | 112 | public SqlQueryLinter(Stream stream) 113 | { 114 | _reader = new BinaryReader(stream); 115 | } 116 | 117 | public void Dispose() 118 | { 119 | _reader.Close(); 120 | } 121 | 122 | private bool Validate(out IReadOnlyCollection errors) 123 | { 124 | var collectedErrors = (List) null; 125 | 126 | // sql(()=> 127 | // 01234567 128 | var lastChars = new char[8]; 129 | var lastCharsPos = 0; 130 | var isInSqlContext = false; 131 | while (ReadNext(out var ch)) 132 | { 133 | if (char.IsWhiteSpace(ch)) 134 | { 135 | continue; 136 | } 137 | 138 | lastChars[lastCharsPos % lastChars.Length] = ch; 139 | lastCharsPos += 1; 140 | 141 | if (IsSqlContextStart(lastChars, lastCharsPos - 1)) 142 | { 143 | if (isInSqlContext) 144 | { 145 | throw new InvalidOperationException( 146 | "Unexpected 'sql(()=>' was found. " + 147 | "It looks like a bug in linter, please report to FunnyDB developer."); 148 | } 149 | 150 | isInSqlContext = true; 151 | continue; 152 | } 153 | 154 | if (IsLongString(lastChars, lastCharsPos - 1, out var isInterpolation)) 155 | { 156 | if (!ValidateLongString(isInSqlContext && isInterpolation, out var stringErrors)) 157 | { 158 | collectedErrors = collectedErrors ?? new List(); 159 | collectedErrors.AddRange(stringErrors); 160 | continue; 161 | } 162 | } 163 | 164 | if (IsShortString(lastChars, lastCharsPos - 1, out isInterpolation)) 165 | { 166 | if (!ValidateShortString(isInSqlContext && isInterpolation, out var stringErrors)) 167 | { 168 | collectedErrors = collectedErrors ?? new List(); 169 | collectedErrors.AddRange(stringErrors); 170 | } 171 | } 172 | } 173 | 174 | errors = (IReadOnlyCollection) collectedErrors ?? Array.Empty(); 175 | return errors.Count == 0; 176 | } 177 | 178 | private bool ValidateLongString(bool validateInterpolation, out List errors) 179 | { 180 | errors = new List(); 181 | while (ReadNext(out var ch)) 182 | { 183 | if (ch == '"' && PeekNext(out var nextCh) && nextCh != '"') 184 | { 185 | return errors.Count == 0; 186 | } 187 | 188 | if (!validateInterpolation) 189 | { 190 | continue; 191 | } 192 | 193 | ValidateInterpolation(errors, ch); 194 | } 195 | 196 | return errors.Count == 0; 197 | } 198 | 199 | private bool ValidateShortString(bool validateInterpolation, out List errors) 200 | { 201 | errors = new List(); 202 | 203 | char? prevCh = null; 204 | while (ReadNext(out var ch)) 205 | { 206 | try 207 | { 208 | if (ch == '"' && prevCh.HasValue && prevCh.Value != '\\') 209 | { 210 | return errors.Count == 0; 211 | } 212 | 213 | if (!validateInterpolation) 214 | { 215 | continue; 216 | } 217 | 218 | ValidateInterpolation(errors, ch); 219 | } 220 | finally 221 | { 222 | prevCh = ch; 223 | } 224 | } 225 | 226 | return errors.Count == 0; 227 | } 228 | 229 | private void ValidateInterpolation(ICollection errors, char ch) 230 | { 231 | if (ch == '{' && PeekNext(out var nextCh) && nextCh == '{') 232 | { 233 | ReadNext(out _); 234 | return; 235 | } 236 | 237 | if (ch != '{' || !PeekNext(out nextCh)) 238 | { 239 | return; 240 | } 241 | 242 | if (nextCh != 'p' && nextCh != 's') 243 | { 244 | errors.Add(new Error(_line, _position)); 245 | return; 246 | } 247 | 248 | ReadNext(out _); 249 | if (PeekNext(out nextCh) && nextCh != '(') 250 | { 251 | errors.Add(new Error(_line, _position)); 252 | } 253 | } 254 | 255 | private static bool IsSqlContextStart(char[] lastChars, int pos) 256 | { 257 | if (pos < 7) 258 | { 259 | return false; 260 | } 261 | 262 | var l = lastChars.Length; 263 | return lastChars[(pos - 7) % l] == 's' 264 | && lastChars[(pos - 6) % l] == 'q' 265 | && lastChars[(pos - 5) % l] == 'l' 266 | && lastChars[(pos - 4) % l] == '(' 267 | && lastChars[(pos - 3) % l] == '(' 268 | && lastChars[(pos - 2) % l] == ')' 269 | && lastChars[(pos - 1) % l] == '=' 270 | && lastChars[(pos - 0) % l] == '>'; 271 | } 272 | 273 | private static bool IsLongString(char[] lastChars, int pos, out bool isInterpolation) 274 | { 275 | if (pos < 2) 276 | { 277 | isInterpolation = false; 278 | return false; 279 | } 280 | 281 | var l = lastChars.Length; 282 | if (lastChars[(pos - 2) % l] == '$' && lastChars[(pos - 1) % l] == '@' && lastChars[(pos - 0) % l] == '"') 283 | { 284 | isInterpolation = true; 285 | return true; 286 | } 287 | 288 | if (lastChars[(pos - 2) % l] == '@' && lastChars[(pos - 1) % l] == '$' && lastChars[(pos - 0) % l] == '"') 289 | { 290 | isInterpolation = true; 291 | return true; 292 | } 293 | 294 | if (lastChars[(pos - 1) % l] == '@' && lastChars[(pos - 0) % l] == '"') 295 | { 296 | isInterpolation = false; 297 | return true; 298 | } 299 | 300 | isInterpolation = false; 301 | return false; 302 | } 303 | 304 | private static bool IsShortString(char[] lastChars, int pos, out bool isInterpolation) 305 | { 306 | if (pos < 1) 307 | { 308 | isInterpolation = false; 309 | return false; 310 | } 311 | 312 | var l = lastChars.Length; 313 | if (lastChars[(pos - 1) % l] == '$' && lastChars[(pos - 0) % l] == '"') 314 | { 315 | isInterpolation = true; 316 | return true; 317 | } 318 | 319 | if (lastChars[(pos - 0) % l] == '"') 320 | { 321 | isInterpolation = false; 322 | return true; 323 | } 324 | 325 | isInterpolation = false; 326 | return false; 327 | } 328 | 329 | 330 | private bool PeekNext(out char ch) 331 | { 332 | var peek = _reader.PeekChar(); 333 | if (peek == -1) 334 | { 335 | ch = default; 336 | return false; 337 | } 338 | 339 | ch = (char) peek; 340 | return true; 341 | } 342 | 343 | private bool ReadNext(out char ch) 344 | { 345 | if (_reader.PeekChar() == -1) 346 | { 347 | ch = default; 348 | return false; 349 | } 350 | 351 | ch = _reader.ReadChar(); 352 | if (ch == '\n') 353 | { 354 | _line += 1; 355 | _position = 0; 356 | } 357 | else 358 | { 359 | _position += 1; 360 | } 361 | 362 | return true; 363 | } 364 | 365 | public class FileError 366 | { 367 | public FileError(string path, Error error) 368 | { 369 | Path = path; 370 | Error = error; 371 | } 372 | 373 | public readonly string Path; 374 | public readonly Error Error; 375 | } 376 | 377 | public class Error 378 | { 379 | public Error(int line, int position) 380 | { 381 | Line = line; 382 | Position = position; 383 | } 384 | 385 | public readonly int Line; 386 | public readonly int Position; 387 | } 388 | } 389 | } --------------------------------------------------------------------------------