Skip to content

Commit

Permalink
Merge pull request #110 from teknologi-umum/query
Browse files Browse the repository at this point in the history
Add SQL
  • Loading branch information
ronnygunawan authored Feb 17, 2024
2 parents 7864a30 + 84f83bb commit 800d38a
Show file tree
Hide file tree
Showing 18 changed files with 674 additions and 16 deletions.
58 changes: 58 additions & 0 deletions BotNet.CommandHandlers/BotUpdate/Message/MessageUpdateHandler.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using BotNet.Commands;
using BotNet.Commands.BotUpdate.Message;
using BotNet.Commands.CommandPrioritization;
using BotNet.Commands.SQL;
using BotNet.Services.BotProfile;
using BotNet.Services.SocialLink;
using RG.Ninja;
using SqlParser;
using SqlParser.Ast;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;

Expand Down Expand Up @@ -157,6 +160,61 @@ out AIFollowUpMessage? aiFollowUpMessage
);

await _commandQueue.DispatchAsync(aiFollowUpMessage);
return;
}

// Handle SQL
if (update.Message is {
ReplyToMessage: null,
Text: { } text
} && text.StartsWith("select", StringComparison.OrdinalIgnoreCase)) {
try {
Sequence<Statement> ast = new SqlParser.Parser().ParseSql(text);
if (ast.Count > 1) {
// Fire and forget
Task _ = Task.Run(async () => {
try {
await _telegramBotClient.SendTextMessageAsync(
chatId: update.Message.Chat.Id,
text: $"Your SQL contains more than one statement.",
replyToMessageId: update.Message.MessageId,
cancellationToken: cancellationToken
);
} catch (OperationCanceledException) {
// Terminate gracefully
}
});
return;
}
if (ast[0] is not Statement.Select selectStatement) {
// Fire and forget
Task _ = Task.Run(async () => {
try {
await _telegramBotClient.SendTextMessageAsync(
chatId: update.Message.Chat.Id,
text: $"Your SQL is not a SELECT statement.",
replyToMessageId: update.Message.MessageId,
cancellationToken: cancellationToken
);
} catch (OperationCanceledException) {
// Terminate gracefully
}
});
return;
}
if (SQLCommand.TryCreate(
message: update.Message,
commandPriorityCategorizer: _commandPriorityCategorizer,
sqlCommand: out SQLCommand? sqlCommand
)) {
await _commandQueue.DispatchAsync(
command: sqlCommand
);
return;
}
} catch {
// Suppress
}
}
}
}
Expand Down
184 changes: 184 additions & 0 deletions BotNet.CommandHandlers/SQL/SQLCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
using System.Text;
using BotNet.Commands.SQL;
using BotNet.Services.SQL;
using BotNet.Services.Sqlite;
using Microsoft.Extensions.DependencyInjection;
using SqlParser.Ast;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;

namespace BotNet.CommandHandlers.SQL {
public sealed class SQLCommandHandler(
ITelegramBotClient telegramBotClient,
IServiceProvider serviceProvider
) : ICommandHandler<SQLCommand> {
private readonly ITelegramBotClient _telegramBotClient = telegramBotClient;
private readonly IServiceProvider _serviceProvider = serviceProvider;

public async Task Handle(SQLCommand command, CancellationToken cancellationToken) {
if (command.SelectStatement.Query.Body.AsSelectExpression().Select.From is not { } froms
|| froms.Count == 0) {
await _telegramBotClient.SendTextMessageAsync(
chatId: command.Chat.Id,
text: "No FROM clause found.",
replyToMessageId: command.SQLMessageId,
cancellationToken: cancellationToken
);
return;
}

// Collect table names from query
HashSet<string> tables = new();
foreach (TableWithJoins from in froms) {
if (from.Relation != null) {
CollectTableNames(ref tables, from.Relation);
}

if (from.Joins != null) {
foreach (Join join in from.Joins) {
if (join.Relation != null) {
CollectTableNames(ref tables, join.Relation);
}
}
}
}

// Create scoped for scoped database
using IServiceScope serviceScope = _serviceProvider.CreateScope();

// Load tables into memory
foreach (string table in tables) {
IScopedDataSource? dataSource = serviceScope.ServiceProvider.GetKeyedService<IScopedDataSource>(table);
if (dataSource == null) {
await _telegramBotClient.SendTextMessageAsync(
chatId: command.Chat.Id,
text: $$"""
Table '{{table}}' not found. Available tables are:
- pilpres
""",
replyToMessageId: command.SQLMessageId,
cancellationToken: cancellationToken
);
return;
}

await dataSource.LoadTableAsync(cancellationToken);
}

// Execute query
using ScopedDatabase scopedDatabase = serviceScope.ServiceProvider.GetRequiredService<ScopedDatabase>();
StringBuilder resultBuilder = new();
scopedDatabase.ExecuteReader(
commandText: command.RawStatement,
readAction: (reader) => {
string[] values = new string[reader.FieldCount];

// Get column names
for (int i = 0; i < reader.FieldCount; i++) {
values[i] = '"' + reader.GetName(i).Replace("\"", "\"\"") + '"';
}
resultBuilder.AppendLine(string.Join(',', values));

// Get rows
while (reader.Read()) {
for (int i = 0; i < reader.FieldCount; i++) {
if (reader.IsDBNull(i)) {
values[i] = "";
continue;
}

Type fieldType = reader.GetFieldType(i);
if (fieldType == typeof(string)) {
values[i] = '"' + reader.GetString(i).Replace("\"", "\"\"") + '"';
} else if (fieldType == typeof(int)) {
values[i] = reader.GetInt32(i).ToString();
} else if (fieldType == typeof(long)) {
values[i] = reader.GetInt64(i).ToString();
} else if (fieldType == typeof(float)) {
values[i] = reader.GetFloat(i).ToString();
} else if (fieldType == typeof(double)) {
values[i] = reader.GetDouble(i).ToString();
} else if (fieldType == typeof(decimal)) {
values[i] = reader.GetDecimal(i).ToString();
} else if (fieldType == typeof(bool)) {
values[i] = reader.GetBoolean(i).ToString();
} else if (fieldType == typeof(DateTime)) {
values[i] = reader.GetDateTime(i).ToString();
} else if (fieldType == typeof(byte[])) {
values[i] = BitConverter.ToString(reader.GetFieldValue<byte[]>(i)).Replace("-", "");
} else {
values[i] = reader[i].ToString();

Check warning on line 110 in BotNet.CommandHandlers/SQL/SQLCommandHandler.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference assignment.

Check warning on line 110 in BotNet.CommandHandlers/SQL/SQLCommandHandler.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference assignment.
}
}
resultBuilder.AppendLine(string.Join(',', values));
}
}
);

// Send result
await _telegramBotClient.SendTextMessageAsync(
chatId: command.Chat.Id,
text: "```csv\n" + resultBuilder.ToString() + "```",
parseMode: ParseMode.MarkdownV2,
replyToMessageId: command.SQLMessageId,
cancellationToken: cancellationToken
);

return;
}

private static void CollectTableNames(ref HashSet<string> tables, TableFactor tableFactor) {
switch (tableFactor) {
case TableFactor.Derived derived:
if (derived.SubQuery.Body.AsSelectExpression().Select.From is { } derivedFroms) {
foreach (TableWithJoins derivedFrom in derivedFroms) {
if (derivedFrom.Relation != null) {
CollectTableNames(ref tables, derivedFrom.Relation);
}

if (derivedFrom.Joins != null) {
foreach (Join join in derivedFrom.Joins) {
if (join.Relation != null) {
CollectTableNames(ref tables, join.Relation);
}
}
}
}
}
break;
case TableFactor.Function function:
break;
case TableFactor.JsonTable jsonTable:
break;
case TableFactor.NestedJoin nestedJoin:
if (nestedJoin.TableWithJoins != null) {
if (nestedJoin.TableWithJoins.Relation != null) {
CollectTableNames(ref tables, nestedJoin.TableWithJoins.Relation);
}

if (nestedJoin.TableWithJoins.Joins != null) {
foreach (Join join in nestedJoin.TableWithJoins.Joins) {
if (join.Relation != null) {
CollectTableNames(ref tables, join.Relation);
}
}
}
}
break;
case TableFactor.Pivot pivot:
CollectTableNames(ref tables, pivot.TableFactor);
break;
case TableFactor.Table table:
tables.Add(table.Name.ToString());
break;
case TableFactor.TableFunction tableFunction:
break;
case TableFactor.UnNest unNest:
break;
case TableFactor.Unpivot unpivot:
tables.Add(unpivot.Name.ToString());
break;
}
}
}
}
4 changes: 3 additions & 1 deletion BotNet.Commands/BotNet.Commands.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@

<ItemGroup>
<PackageReference Include="MediatR" Version="12.2.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.2" />
<PackageReference Include="SqlParserCS" Version="0.2.2" />
<PackageReference Include="Telegram.Bot" Version="19.0.0" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.1" />
</ItemGroup>

</Project>
74 changes: 74 additions & 0 deletions BotNet.Commands/SQL/SQLCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System.Diagnostics.CodeAnalysis;
using BotNet.Commands.BotUpdate.Message;
using BotNet.Commands.ChatAggregate;
using BotNet.Commands.CommandPrioritization;
using SqlParser;
using SqlParser.Ast;

namespace BotNet.Commands.SQL {
public sealed record SQLCommand : ICommand {
public string RawStatement { get; }
public Statement.Select SelectStatement { get; }
public MessageId SQLMessageId { get; }
public ChatBase Chat { get; }

private SQLCommand(
string rawStatement,
Statement.Select selectStatement,
MessageId sqlMessageId,
ChatBase chat
) {
RawStatement = rawStatement;
SelectStatement = selectStatement;
SQLMessageId = sqlMessageId;
Chat = chat;
}

public static bool TryCreate(
Telegram.Bot.Types.Message message,
CommandPriorityCategorizer commandPriorityCategorizer,
[NotNullWhen(true)] out SQLCommand? sqlCommand
) {
// Must start with select
if (message.Text is not { } text || !text.StartsWith("select", StringComparison.OrdinalIgnoreCase)) {
sqlCommand = null;
return false;
}

// Chat must be private or group
if (!ChatBase.TryCreate(message.Chat, commandPriorityCategorizer, out ChatBase? chat)) {
sqlCommand = null;
return false;
}

// Must be a valid SQL statement
Sequence<Statement> ast;
try {
ast = new SqlParser.Parser().ParseSql(text);
} catch {
sqlCommand = null;
return false;
}

// Can only contain one statement
if (ast.Count != 1) {
sqlCommand = null;
return false;
}

// Must be a SELECT statement
if (ast[0] is not Statement.Select selectStatement) {
sqlCommand = null;
return false;
}

sqlCommand = new(
rawStatement: text,
selectStatement: selectStatement,
sqlMessageId: new(message.MessageId),
chat: chat
);
return true;
}
}
}
16 changes: 9 additions & 7 deletions BotNet.Services/BotNet.Services.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,24 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.0.7" />
<PackageReference Include="AngleSharp" Version="1.1.0" />
<PackageReference Include="DynamicExpresso.Core" Version="2.16.1" />
<PackageReference Include="Grpc.Net.Client" Version="2.59.0" />
<PackageReference Include="Google.Apis.Sheets.v4" Version="1.66.0.3148" />
<PackageReference Include="Grpc.Net.Client" Version="2.60.0" />
<PackageReference Include="Microsoft.ClearScript" Version="7.4.4" />
<PackageReference Include="Microsoft.ClearScript.V8.Native.linux-x64" Version="7.4.4" />
<PackageReference Include="Microsoft.Data.Sqlite.Core" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
<PackageReference Include="Microsoft.Maui.Graphics.Skia" Version="8.0.3" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.1" />
<PackageReference Include="Microsoft.Maui.Graphics.Skia" Version="8.0.6" />
<PackageReference Include="RG.Ninja" Version="1.0.8" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.6" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.7" />
<PackageReference Include="Telegram.Bot" Version="19.0.0" />
<PackageReference Include="Google.Protobuf" Version="3.25.1" />
<PackageReference Include="Google.Protobuf" Version="3.25.2" />
<PackageReference Include="Grpc" Version="2.46.6" />
<PackageReference Include="Grpc.Tools" Version="2.59.0">
<PackageReference Include="Grpc.Tools" Version="2.60.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
Expand Down
12 changes: 12 additions & 0 deletions BotNet.Services/GoogleSheets/FromColumnAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System;

namespace BotNet.Services.GoogleSheets {
[AttributeUsage(validOn: AttributeTargets.Property, AllowMultiple = false)]
public sealed class FromColumnAttribute : Attribute {
public string Column { get; }

public FromColumnAttribute(string column) {
Column = column;
}
}
}
Loading

0 comments on commit 800d38a

Please sign in to comment.