diff --git a/README.ja.md b/README.ja.md
index 5d3bbe8..9f9054f 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -32,7 +32,15 @@ $ open http://localhost:3939
[SQLiteアーカイブ](https://sqlite.org/sqlar.html)(通称:sqlar)とはファイルをBLOBとして保存するための標準テーブルスキーマ1のあるただのSQLiteデータベースである。そうする理由はメインページがいくつか説明する(私見では、主のはリレーショナルかインデックスされたデータとファイルが一緒に格納されて、同じORMが使えて、そしてファイルが外部に保存される場合に不可能の外部キー制約ができることだ2)でもsqlarフォーマットを特別に作ったテーブルの代わりに使用するメリットはsqlite3のCLIのtarみたいなオプション(そしてこれ)が使えることだ。
-私がこれを作る動機は、大きいデータセットと付属オーディオファイルを持って、導入時にそれぞれElasticsearchとS3にプッシュするけどその時までどこかの中間の場所で保存しないとならなかった。私がすでにEntity Frameworkを使ってデータをsqliteファイルに書き込んでいて、最初はオーディオファイルをフォルダーに保存していたけど、ファイル名がSHA1ハッシュだと考えると、このようにアクセスできるようにするのは特に意味がなかった。逆に、Windowsファイルパスや存在しないファイル等を扱うことをかかえた。ファイルをデータベースに移動するのは主キーがS3のオブジェクト名と同じもので、そのテーブルを外部キーによって参照してデータ整合性を強化できるという意味した。それにラップトップとデスクトップを切り替えるために一つのファイルをコピーしていいから楽になった3。
+> [!TIP]
+> [ビュー](https://ja.wikipedia.org/wiki/%E3%83%93%E3%83%A5%E3%83%BC_(%E3%83%87%E3%83%BC%E3%82%BF%E3%83%99%E3%83%BC%E3%82%B9))を使用して、既存のテーブルをsqlarスキーマに合わせることができる(現在sqlarserverのみサポートする):
+> ```sql
+> CREATE VIEW sqlar(rowid, name, mode, mtime, sz, data) AS
+> SELECT rowid, filename, 33279, 0, length(content), content FROM files;
+> ```
+> サーバーはパフォーマンスのためにSQLiteのBlob APIを使うので、rowid(ビューに含まれた)と元になるテーブル名と列名を知る必要がある。実行時に`-e BlobTable=files -e BlobColumn=content`として渡す。
+
+私がこれを作る動機は、大きいデータセットと付属オーディオファイルを持って、導入時にそれぞれElasticsearchとS3にプッシュするけどその時までどこかの中間の場所で保存しないとならなかった。最初はオーディオファイルをフォルダーに保存していたけど、ファイル名がSHA1ハッシュだと考えると、このようにアクセスできるようにするのは特に意味がなかった。逆に、Windowsファイルパスや存在しないファイル等を扱うことをかかえた。ファイルをデータベースに移動したため、主キーがS3のオブジェクト名と同じもので、そのテーブルを外部キーによって参照してデータ整合性を強化できるようになった。それにラップトップとデスクトップを切り替えるために一つのファイルをコピーしていいから楽になった3。
でも問題があった。開発環境ではローカルサーバーが本番のS3バケットではなくそのローカルのファイルに指してほしかった。別の開発バケットなんて不要だった。ディスク上のファイルだったら単純に静的ファイルとして提供できたけど、本番で要らないMicrosoft.Data.Sqliteへの依存関係を追加してDockerイメージを膨らましたくなかった。プリプロセッサ ディレクティブは醜い。どうしよう?BLOBを静的ファイルとして提供するだけの別のコンテイナーをComposeファイルに追加して開発でS3の代わりにしようか!そしてNginxやApacheみたいなディレクトリリスト機能も入れって、必要のないのにシンボリックリンク対応を実装しよう!それでそれで……FTPサーバーを!なんていいアイデアだ!けして全く別のプロジェクトに膨大してしまわないでしょう!
@@ -52,13 +60,15 @@ $ open http://localhost:3939
|環境変数|デフォルト値 |説明|
|---|---|---|
-|`TZ`|UTC|変更時刻を表示するためのタイムゾーン ([_List of tz database time zones_](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)を参照)|
+|`TZ`|UTC|変更時刻を表示するための[タイムゾーン](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)|
|`LANG`|en_US|数値等をフォーマットするためのロケール|
|`SizeFormat`|Binary|Bytes = ファイルサイズを書式なしでバイトで表示する
Binary = バイナリ単位を使う(KiB、MiB、GiB、TiB)
SI = SI単位を使う(KB、MB、GB、TB)|
|`DirectoriesFirst`|true|ディレクトリをファイルの前にグループする|
|`CaseInsensitive`|false|大文字小文字を区別しないファイルシステムとして扱う|
|`StaticSite`|false|ディレクトリリストを無効して、存在するとindex.htmlを提供して、見つけられない場合/404.htmlを提供する|
|`Charset`|utf-8|ファイルストリームのContent-Typeヘッダーの文字コードを設定する。無効にするには空の文字列に設定して。|
+|`BlobTable`|sqlar|BLOBを含むテーブル名
※ sqlarテーブルとしてビューを使用することに関する上記のTipを参照してください|
+|`BlobColumn`|data|BLOBを含む列名
※ sqlarテーブルとしてビューを使用することに関する上記のTipを参照してください|
|`EnableFtp`|false|FTPサーバーを起動する|
|`FtpPasvPorts`|10000-10009|受動モードのためのポート範囲。ホストとコンテイナーのポートは一致する必要がある。ポート数が大きい場合はDockerが遅くなる可能性があるので、広い範囲は推奨しない。|
|`FtpPasvAddress`|127.0.0.1|FTPサーバーの外部IPアドレス|
@@ -75,7 +85,7 @@ $ open http://localhost:3939
rin.avifイラストはとしたのあ (@Noartnolife1227)による
-Junkerの[C#のFTPサーバー](https://github.com/FubarDevelopment/FtpServer/)がファイルシステムの抽象化のインタフェースがあるのお陰で、私はバックエンドとしてsqlarserverの内部用のファイルツリーを使う実装を作成できた。これでSQLiteアーカイブの内容をFTP経由で参照できる!
+FubarDevの[C#のFTPサーバー](https://github.com/FubarDevelopment/FtpServer/)がファイルシステムの抽象化のインタフェースがあるのお陰で、私はバックエンドとしてsqlarserverの内部用のファイルツリーを使う実装を作成できた。これでSQLiteアーカイブの内容をFTP経由で参照できる!
![だがなぜ](.github/images/but%20why.avif)
@@ -95,7 +105,7 @@ Junkerの[C#のFTPサーバー](https://github.com/FubarDevelopment/FtpServer/)
ポート21は何でもにマッピングできるけど、FTPプロトコルの一部はサーバーがデータ転送のためにどのIPとポートに接続すべきだとクライアントに伝えることなので、PASV1のポート範囲の10000-10009はホストとコンテイナーが一致する必要がある。`FtpPasvPorts`の設定で変更できる。もしサーバーがlocalhostで実行してなければ`FtpPasvAddress`をFTPクライアントに入力すると同じIPアドレスに設定する必要がある。
-> 1 PASVとはFTPの受動モード(英:passive mode)に指して、サーバーがデータ転送のためにポートを開けてクライアントに接続するように指し示すことだ。(ポート21はコントロールで、コマンド通信のためでけに使われる。)その反対はアクティブモードで、ファイアウォールの前の時代🦕からだしサーバーが直接にクライアントへの接続を確立することだった。
+> 1 PASVとはFTPの受動モード(英:passive mode)に指して、サーバーがデータ転送のためにポートを開けてクライアントに接続するように指し示すことだ。(ポート21はコントロールで、コマンド通信のためだけに使われる。)その反対はアクティブモードで、ファイアウォールの前の時代🦕からだしサーバーが直接にクライアントへの接続を確立することだった。
> [!WARNING]
> Dockerはデフォルトで公開されたポートを0.0.0.0にバインドして外部からアクセスできるようにファイアウォール規則を作るのだ。信頼できないネットワークでは、またはマシンがインターネットに開けてる場合は、明示的にlocalhostにバインドする(例えば、`-p 127.0.0.1:21:21`)か[デフォルトのバインド・アドレスを変更](https://docs.docker.com/network/packet-filtering-firewalls/#setting-the-default-bind-address-for-containers)しないとならない。(DockerのLinux版を使用しているWSLユーザーはこれを気にする必要はないはずだ。)
@@ -119,7 +129,7 @@ $ docker run -it --rm -v .:/srv -p 3939:80 -e StaticSite=true ghcr.io/maxkagamin
>
> 2 中身を覗いてみて物事を実行しているDockerコンテイナーを見ない限りよね。でもインフラを無視するこそがサーバーレスじゃない?
>
-> 3 2をご参照。でもこれを[AOTコンパイル](https://learn.microsoft.com/ja-jp/aspnet/core/fundamentals/native-aot?view=aspnetcore-8.0)できるなら、SQLiteデータベースを実行可能に埋め込む方法があると思う…🤔
+> 3 2をご参照。でもこれを[AOTコンパイル](https://learn.microsoft.com/ja-jp/aspnet/core/fundamentals/native-aot?view=aspnetcore-8.0)できるなら、SQLiteデータベースを実行ファイルに埋め込む方法があると思う…🤔
## ライセンス
diff --git a/README.md b/README.md
index 9da9d43..8ee16b8 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,15 @@ $ open http://localhost:3939
An [SQLite Archive](https://sqlite.org/sqlar.html), or _sqlar_ for short (probably pronounced /ˈɛs kjuː ˈlɑːr/, but I sometimes pronounce it /sklɑːr/ because "sqlarball" sounds funnier that way), is simply an SQLite database that uses a standard table schema¹ for storing files as blobs. The main page goes over some reasons for doing this (the big ones IMO being that your relational/indexed data and files are kept together, use the same ORM, and can have foreign key constraints that simply aren't possible when files are stored externally²), but the advantage of using the sqlar format over an ad hoc table is that it enables use of the sqlite3 CLI's tar-like options — and now this as well.
-My motivation for making this was I had a large dataset and accompanying audio files, where upon deployment these would be pushed to Elasticsearch and S3 respectively, but until then needed to be stored in some intermediate location. I was already using Entity Framework and dumping the data into an sqlite file; at first, I was saving the audio files to a folder, but given that their filenames were SHA1 hashes, there wasn't really any meaning in having them accessible in this way. Rather, it burdened me with having to deal with Windows file paths, potential for missing files, and so on. Moving them into the sqlite file itself meant that the primary key was literally the S3 object name, and I could reference this table via a foreign key to enforce data integrity. Switching between my laptop and desktop is easier, too, as I can just copy the file over.³
+> [!TIP]
+> You can use a [view](https://en.wikipedia.org/wiki/View_(SQL)) to adapt an existing table to the sqlar schema (only sqlarserver supports this currently):
+> ```sql
+> CREATE VIEW sqlar(rowid, name, mode, mtime, sz, data) AS
+> SELECT rowid, filename, 33279, 0, length(content), content FROM files;
+> ```
+> The server uses SQLite's blob API for performance, so it needs to know the rowid (which we've included in the view) and the name of the underlying table and its blob column, which should be passed when running the server as `-e BlobTable=files -e BlobColumn=content`.
+
+My motivation for making this was I had a large dataset and accompanying audio files, where upon deployment these would be pushed to Elasticsearch and S3 respectively, but until then needed to be stored in some intermediate location. At first, I was saving the audio files to a folder, but given that their filenames were SHA1 hashes, there wasn't really any meaning in having them accessible in this way. Rather, it burdened me with having to deal with Windows file paths, potential for missing files, and so on. Moving them into the sqlite file itself meant that the primary key was literally the S3 object name, and I could reference this table via a foreign key to enforce data integrity. Switching between my laptop and desktop is easier, too, as I can just copy the file over.³
But this provided a challenge: for development, I wanted my local server to point to the local audio files, not the production S3 bucket. A separate dev bucket would be overkill. Had these been files on disk, I could simply serve them as static files, but I didn't want to add Microsoft.Data.Sqlite as a dependency and inflate the docker image when it wouldn't need that in production. Preprocessor directives are ugly. What to do? Why not add a separate container to the compose file that just serves the blobs as static files, and use that as a stand-in for S3 in dev! And then, let's add directory listing, like Nginx or Apache! And symlink support, too, even though I wouldn't need it! And, and... _an FTP server!_ What a great idea! Surely this won't balloon into a whole project!
@@ -52,13 +60,15 @@ The following options can be passed as environment variables:
|Environment variable|Default |Description|
|---|---|---|
-|`TZ`|UTC|Timezone for displaying date modified (see [_List of tz database time zones_](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones))|
+|`TZ`|UTC|[Timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for displaying date modified|
|`LANG`|en_US|Locale used for formatting numbers etc.|
|`SizeFormat`|Binary|Bytes = Display file sizes in bytes without formatting
Binary = Use binary units (KiB, MiB, GiB, TiB)
SI = Use SI units (KB, MB, GB, TB)|
|`DirectoriesFirst`|true|Group directories before files|
|`CaseInsensitive`|false|Treat the archive as a case-insensitive filesystem|
|`StaticSite`|false|Disable directory listing and serve index.html files where present and /404.html when not found|
|`Charset`|utf-8|Sets the charset in the Content-Type header of file streams. Empty string to disable.|
+|`BlobTable`|sqlar|Name of the table holding the blob
_See the tip above on using a view for the sqlar table_|
+|`BlobColumn`|data|Name of the column holding the blob
_See the tip above on using a view for the sqlar table_|
|`EnableFtp`|false|Start the FTP server|
|`FtpPasvPorts`|10000-10009|Port range used for passive mode. Host and container ports must match. Avoid too large a range, as many ports can make docker slow.|
|`FtpPasvAddress`|127.0.0.1|The FTP server's external IP address|
@@ -75,7 +85,7 @@ The following options can be passed as environment variables:
rin.avif artwork by としたのあ (@Noartnolife1227)
-Thanks to Junker's [C# FTP server](https://github.com/FubarDevelopment/FtpServer/) having an abstracted file system interface, I was able to write an implementation that uses sqlarserver's internal file node tree as a backend. Now you can browse the contents of an SQLite Archive via FTP!
+Thanks to FubarDev's [C# FTP server](https://github.com/FubarDevelopment/FtpServer/) having an abstracted file system interface, I was able to write an implementation that uses sqlarserver's internal file node tree as a backend. Now you can browse the contents of an SQLite Archive via FTP!
![But why?](.github/images/but%20why.avif)
diff --git a/SqliteArchive.Server/appsettings.json b/SqliteArchive.Server/appsettings.json
index 9a7a540..95c781f 100644
--- a/SqliteArchive.Server/appsettings.json
+++ b/SqliteArchive.Server/appsettings.json
@@ -13,6 +13,8 @@
"CaseInsensitive": false,
"StaticSite": false,
"Charset": "utf-8",
+ "BlobTable": "sqlar",
+ "BlobColumn": "data",
"EnableFtp": false,
"FtpPasvPorts": "10000-10009",
"FtpPasvAddress": "127.0.0.1"
diff --git a/SqliteArchive.Tests/SqlarServiceTests.cs b/SqliteArchive.Tests/SqlarServiceTests.cs
index e9f4180..53a837a 100644
--- a/SqliteArchive.Tests/SqlarServiceTests.cs
+++ b/SqliteArchive.Tests/SqlarServiceTests.cs
@@ -1,13 +1,13 @@
// Copyright (c) Max Kagamine
// Licensed under the Apache License, Version 2.0
-using System.Text;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using SqliteArchive.Nodes;
+using System.Text;
using Xunit;
namespace SqliteArchive.Tests;
@@ -22,7 +22,7 @@ public sealed class SqlarServiceTests : IDisposable
private readonly SqliteConnection connection;
- private SqlarOptions options = new();
+ private SqlarOptions options = new() { BlobTable = "sqlar", BlobColumn = "data" };
private ILogger logger = NullLogger.Instance;
public SqlarServiceTests()
@@ -477,4 +477,68 @@ public void CaseInsensitiveFileSystem()
It.Is((o, t) => o.ToString() == "Path \"/dir/Foo\" exists in the archive multiple times."),
null, It.IsAny>()));
}
+
+ [Fact]
+ public void SupportsViews()
+ {
+ ReadOnlySpan expectedContent = "鏡音リン"u8;
+
+ // Create a table with the schema suggested by https://sqlite.org/appfileformat.html
+ using var createTable = connection.CreateCommand();
+ createTable.CommandText = """
+ CREATE TABLE files(filename TEXT PRIMARY KEY, content BLOB);
+ """;
+ createTable.ExecuteNonQuery();
+
+ // Add files
+ for (int i = 0; i < 3; i++)
+ {
+ using var insert = connection.CreateCommand();
+ insert.CommandText = """
+ INSERT INTO files(filename, content)
+ VALUES($name, zeroblob($length));
+ SELECT last_insert_rowid();
+ """;
+ insert.Parameters.AddWithValue("$name", $"file{i}.txt");
+ insert.Parameters.AddWithValue("$length", expectedContent.Length);
+ var rowId = (long)insert.ExecuteScalar()!;
+
+ using var blob = new SqliteBlob(connection, "files", "content", rowId);
+ blob.Write(expectedContent);
+ }
+
+ // Create a view
+ // Note: This must contain an additional "rowid" column to enable direct blob access
+ using var createView = connection.CreateCommand();
+ createView.CommandText = """
+ CREATE VIEW sqlar(rowid, name, mode, mtime, sz, data) AS
+ SELECT rowid, filename, 33279, 0, length(content), content FROM files;
+ """;
+ createView.ExecuteNonQuery();
+
+ // Instantiate service
+ var service = new SqlarService(connection, Options.Create(options with
+ {
+ BlobTable = "files",
+ BlobColumn = "content"
+ }), logger);
+
+ // Attempt to list files and retrieve blob
+ var root = service.FindPath("/") as DirectoryNode;
+
+ Assert.NotNull(root);
+ Assert.Equal(3, root.Children.Count());
+
+ var file = service.FindPath("/file1.txt") as FileNode;
+
+ Assert.NotNull(file);
+ Assert.Equal(Permissions.All, file.Mode.Permissions);
+ Assert.Equal(DateTime.UnixEpoch, file.DateModified);
+
+ using var stream = service.GetStream(file);
+ using var ms = new MemoryStream((int)stream.Length);
+ stream.CopyTo(ms);
+
+ Assert.Equal(expectedContent.ToArray(), ms.ToArray());
+ }
}
diff --git a/SqliteArchive/SqlarOptions.cs b/SqliteArchive/SqlarOptions.cs
index fcdf48d..4145f0f 100644
--- a/SqliteArchive/SqlarOptions.cs
+++ b/SqliteArchive/SqlarOptions.cs
@@ -12,7 +12,7 @@ public record SqlarOptions
Environment variables:
TZ Timezone for displaying date modified (default: UTC)
- See [3m]8;;https://w.wiki/4Jx\List of tz database time zones]8;;\[m
+ [3mSee ]8;;https://w.wiki/4Jx\List of tz database time zones]8;;\[m
LANG Locale used for formatting (default: en_US)
@@ -31,6 +31,12 @@ StaticSite Disable directory listing and serve index.html files where
Charset Sets the charset in the Content-Type header of file streams.
Empty string to disable. (default: utf-8)
+ BlobTable Name of the table holding the blob (default: sqlar)
+ [3mSee the tip in ]8;;https://github.com/maxkagamine/sqlarserver#readme\the readme]8;;\ on using a view for the sqlar table[m
+
+ BlobColumn Name of the column holding the blob (default: data)
+ [3mSee the tip in ]8;;https://github.com/maxkagamine/sqlarserver#readme\the readme]8;;\ on using a view for the sqlar table[m
+
EnableFtp Start the FTP server (default: false)
FtpPasvPorts Port range used for passive mode. Host and container ports
@@ -50,6 +56,10 @@ docker slow. (default: 10000-10009)
public string Charset { get; init; } = "";
+ public string BlobTable { get; init; } = "";
+
+ public string BlobColumn { get; init; } = "";
+
public bool EnableFtp { get; init; }
public string FtpPasvPorts { get; init; } = "";
diff --git a/SqliteArchive/SqlarService.cs b/SqliteArchive/SqlarService.cs
index 5f73535..0cf2a0e 100644
--- a/SqliteArchive/SqlarService.cs
+++ b/SqliteArchive/SqlarService.cs
@@ -1,18 +1,23 @@
// Copyright (c) Max Kagamine
// Licensed under the Apache License, Version 2.0
-using System.Diagnostics.CodeAnalysis;
-using System.IO.Compression;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SqliteArchive.Helpers;
using SqliteArchive.Nodes;
+using System.Diagnostics.CodeAnalysis;
+using System.IO.Compression;
namespace SqliteArchive;
public class SqlarService : ISqlarService
{
+ const string SqlarViewExceptionMessage =
+ "When using a view for the sqlar table, it must include the rowid column from the underlying table, and " +
+ $"{nameof(SqlarOptions.BlobTable)} and {nameof(SqlarOptions.BlobColumn)} must be set to the name of the " +
+ "underlying table and its data column, respectively.";
+
private readonly SqliteConnection connection;
private readonly SqlarOptions options;
private readonly ILogger logger;
@@ -28,7 +33,14 @@ public SqlarService(SqliteConnection connection, IOptions options,
root = new DirectoryNode(options.Value.CaseInsensitive ?
StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
- InitializeFileTree();
+ try
+ {
+ InitializeFileTree();
+ }
+ catch (SqliteException ex) when (ex.Message.Contains("no such column: rowid"))
+ {
+ throw new Exception(SqlarViewExceptionMessage, ex);
+ }
}
public Node? FindPath(string path, bool dereference = false) => FindPath(new Path(path), dereference);
@@ -90,9 +102,16 @@ private Stream GetStream(long rowId, long size)
// [0]: https://sqlite.org/sqlar/doc/trunk/README.md
// [1]: https://github.com/dotnet/efcore/issues/24312
// [2]: https://www.sqlite.org/sqlar.html#managing_sqlite_archives_from_application_code
- var blob = new SqliteBlob(connection, "sqlar", "data", rowId, readOnly: true);
- return size < 0 || blob.Length == size ? blob :
- new ZLibStream(blob, CompressionMode.Decompress, leaveOpen: false);
+ try
+ {
+ var blob = new SqliteBlob(connection, options.BlobTable, options.BlobColumn, rowId, readOnly: true);
+ return size < 0 || blob.Length == size ? blob :
+ new ZLibStream(blob, CompressionMode.Decompress, leaveOpen: false);
+ }
+ catch (SqliteException ex) when (ex.Message.Contains("cannot open view"))
+ {
+ throw new Exception(SqlarViewExceptionMessage, ex);
+ }
}
///