aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJan Tuomi <jan@jantuomi.fi>2025-05-02 23:38:23 +0300
committerJan Tuomi <jan@jantuomi.fi>2025-05-02 23:38:23 +0300
commite17048eddfe2df86abd2d498da2a33b9c3dd8a72 (patch)
tree4e6799cdfb0e05c9df499bcf8ec4d33d8d5ec3fd
parentef72b74ec34e95a38890d542cda298dd6565d5ad (diff)
Add sort_asc param, impl demo chat app
-rw-r--r--demo/app.py231
-rw-r--r--demo/requirements.txt1
-rw-r--r--demo/static/styles.css76
-rw-r--r--demo/templates/base.html.j217
-rw-r--r--demo/templates/frag_aside.html.j227
-rw-r--r--demo/templates/frag_aside_link.html.j29
-rw-r--r--demo/templates/frag_error.html.j216
-rw-r--r--demo/templates/frag_form_delete.html.j27
-rw-r--r--demo/templates/frag_form_find.html.j27
-rw-r--r--demo/templates/frag_form_range.html.j221
-rw-r--r--demo/templates/frag_form_upsert.html.j25
-rw-r--r--demo/templates/frag_messages.html.j212
-rw-r--r--demo/templates/frag_results.html.j218
-rw-r--r--demo/templates/page_error.html.j29
-rw-r--r--demo/templates/page_index.html.j227
-rw-r--r--demo/templates/page_main.html.j211
-rw-r--r--log_db/src/common.rs2
-rw-r--r--log_db/src/engine.rs23
-rw-r--r--log_db/tests/integration.rs3
-rw-r--r--py_bindings/log_db.pyi6
-rw-r--r--py_bindings/src/lib.rs36
21 files changed, 175 insertions, 389 deletions
diff --git a/demo/app.py b/demo/app.py
index be79432..bab20f0 100644
--- a/demo/app.py
+++ b/demo/app.py
@@ -1,19 +1,23 @@
from dotenv import load_dotenv
+from werkzeug.utils import redirect
load_dotenv()
import os
import sys
import traceback
import tempfile
-from typing import cast
from flask import Flask, render_template, request
from flask_compress import Compress # type: ignore
+from flask_apscheduler import APScheduler #type: ignore
+import datetime
+from dataclasses import dataclass
-import log_db
+import uuid
from log_db import DB, Bound, Value
app = Flask(__name__)
Compress(app)
+scheduler = APScheduler()
BASE_URL = os.environ["BASE_URL"]
DB_DIR = os.environ["DB_DIR"] if "DB_DIR" in os.environ else tempfile.TemporaryDirectory().name
@@ -22,26 +26,27 @@ print("""\n"""
f"""BASE_URL: {BASE_URL}\n"""
f"""DB_DIR: {DB_DIR}\n""")
-db_fields = ["id", "name"]
-db_types = ["int", "string"]
+db_fields = ["id", "ts", "username", "message"]
+db_types = ["string", "int", "string", "string"]
+
+@dataclass
+class Message:
+ id: str
+ ts: str
+ username: str
+ msg: str
db = DB \
.configure() \
.data_dir(DB_DIR) \
.fields(db_fields) \
.primary_key("id") \
- .secondary_keys(["name"]) \
+ .secondary_keys(["ts"]) \
.initialize()
def error(e: str, code: int):
error_text = f"HTTP {code}: {e}"
- #return render_template("page_error.html.j2", error = error_text)
-
- htmp_target = request.form.get("htmp") or request.args.get("htmp")
- if htmp_target:
- return render_template("frag_error.html.j2", error = error_text, container = htmp_target)
- else:
- return render_template("page_error.html.j2", error = error_text)
+ return render_template("page_error.html.j2", error = error_text)
@app.errorhandler(Exception)
def error_handler(e: Exception):
@@ -52,191 +57,53 @@ def error_handler(e: Exception):
return error("Internal Server Error", 500)
@app.get("/")
-def index_default():
- return index("find")
-
-@app.get("/<op>")
-def index(op: str):
- if op not in ["find", "range", "upsert", "delete"]:
- return error("Invalid operation", 400)
-
- rows = db.range_by("id", Bound.unbounded(), Bound.unbounded(), limit=100)
- rows = [[value_to_str(v) for v in row] for row in rows]
+def index():
+ messages = get_messages()
htmp_target = request.form.get("htmp") or request.args.get("htmp")
if htmp_target:
- return render_template(f"frag_form_{op}.html.j2")
+ return render_template("frag_messages.html.j2", messages = messages)
else:
- return render_template('page_main.html.j2',
- selected_form = f"frag_form_{op}.html.j2",
- field_names = ["id", "name"],
- rows = rows,
- )
+ return render_template("page_index.html.j2", messages = messages)
-@app.post("/find")
+@app.post("/")
def query_find():
- field = request.form.get("field")
- if not field: raise ValueError("Field is required")
+ username = request.form.get("username")
+ if not username: raise ValueError("username is required")
- values = request.form.get("values")
- if not values: raise ValueError("Values are required")
+ message = request.form.get("message")
+ if not message: raise ValueError("message is required")
- field_index = db_fields.index(field)
- if field_index == -1: raise ValueError(f"Field '{field}' not found")
+ id = uuid.uuid4().hex
+ ts = int(datetime.datetime.now().timestamp())
- try:
- values = [cast_to_value(field, v) for v in values.split("\n")]
- values = [v for v in values if v is not None]
- except ValueError as e:
- return error(str(e), 400)
-
- tagged_rows = db.batch_find_by(field, values, limit=100)
- rows = [[value_to_str(v) for v in row] for (_, row) in tagged_rows]
+ db.upsert([Value.string(id), Value.int(ts), Value.string(username), Value.string(message)])
htmp_target = request.form.get("htmp") or request.args.get("htmp")
if htmp_target:
- return render_template("frag_results.html.j2",
- field_names = ["id", "name"],
- rows = rows,
- )
+ messages = get_messages()
+ return render_template("frag_messages.html.j2", messages = messages)
else:
- return render_template('page_main.html.j2',
- selected_form = "frag_form_find.html.j2",
- field_names = ["id", "name"],
- rows = rows,
- )
-
-@app.post("/range")
-def query_range():
- field = request.form.get("field")
- if not field: raise ValueError("Field is required")
-
- from_type = request.form.get("from_type")
- if not from_type: raise ValueError("From type is required")
-
- to_type = request.form.get("to_type")
- if not to_type: raise ValueError("To type is required")
-
- field_index = db_fields.index(field)
- if field_index == -1: raise ValueError(f"Field '{field}' not found")
+ return redirect("/")
- match from_type:
- case "unbounded":
- bound_lower = Bound.unbounded()
- case "included":
- from_value = request.form.get("from_value")
- try:
- if not from_value: raise ValueError("From value is required")
- from_value = cast_to_value(field, from_value)
- except ValueError as e:
- return error(str(e), 400)
- bound_lower = Bound.included(cast(Value, from_value))
- case "excluded":
- from_value = request.form.get("from_value")
- try:
- if not from_value: raise ValueError("From value is required")
- from_value = cast_to_value(field, from_value)
- except ValueError as e:
- return error(str(e), 400)
- bound_lower = Bound.excluded(cast(Value, from_value))
- case _:
- return error("Invalid from type", 400)
+def get_messages():
+ messages = db.range_by("ts", Bound.unbounded(), Bound.unbounded(), limit=100, sort_asc=False)
- match to_type:
- case "unbounded":
- bound_upper = Bound.unbounded()
- case "included":
- to_value = request.form.get("to_value")
- try:
- if not to_value: raise ValueError("To value is required")
- to_value = cast_to_value(field, to_value)
- except ValueError as e:
- return error(str(e), 400)
- bound_upper = Bound.included(cast(Value, to_value))
- case "excluded":
- to_value = request.form.get("to_value")
- try:
- if not to_value: raise ValueError("To value is required")
- to_value = cast_to_value(field, to_value)
- except ValueError as e:
- return error(str(e), 400)
- bound_upper = Bound.excluded(cast(Value, to_value))
- case _:
- return error("Invalid to type", 400)
-
- tagged_rows = db.range_by(field, bound_lower, bound_upper, limit=100)
- rows = [[value_to_str(v) for v in row] for row in tagged_rows]
-
- htmp_target = request.form.get("htmp") or request.args.get("htmp")
- if htmp_target:
- return render_template("frag_results.html.j2",
- field_names = ["id", "name"],
- rows = rows,
+ return [
+ Message(
+ id=id.as_string(),
+ ts=datetime.datetime.fromtimestamp(ts.as_int()).isoformat(),
+ username=username.as_string(),
+ msg=msg.as_string()
)
- else:
- return render_template('page_main.html.j2',
- selected_form = "frag_form_range.html.j2",
- field_names = ["id", "name"],
- rows = rows,
- )
-
-@app.post("/delete")
-def query_delete():
- field = request.form.get("field")
- if not field: raise ValueError("Field is required")
-
- value = request.form.get("value")
- if not value: raise ValueError("Value is required")
-
- field_index = db_fields.index(field)
- if field_index == -1: raise ValueError(f"Field '{field}' not found")
-
- try:
- value = cast_to_value(field, value)
- except ValueError as e:
- return error(str(e), 400)
-
- rows = db.delete_by(field, cast(Value, value))
- rows = [[value_to_str(v) for v in row] for row in rows]
-
- htmp_target = request.form.get("htmp") or request.args.get("htmp")
- if htmp_target:
- return render_template("frag_results.html.j2",
- field_names = ["id", "name"],
- rows = rows,
- )
- else:
- return render_template('page_main.html.j2',
- selected_form = "frag_form_delete.html.j2",
- field_names = ["id", "name"],
- rows = rows,
- )
-
-
-# Utils
-
-def cast_to_value(field: str, str_value: str) -> Value | None:
- str_value = str_value.strip()
- if str_value == "": return None
- if str_value[0] == "\"":
- if str_value[-1] != "\"": raise ValueError(f"Invalid string: {str_value}")
- str_value = str_value[1:-1]
-
- field_index = db_fields.index(field)
- if field_index == -1: raise ValueError(f"Field '{field}' not found")
-
- type = db_types[field_index]
+ for [id, ts, username, msg] in messages
+ ]
- match type:
- case "int": return Value.int(int(str_value))
- case "string": return Value.string(str_value)
- case _: raise ValueError(f"Unsupported type: {type}")
+@scheduler.task('interval', id='my_job', minutes=1)
+def run_maintenance():
+ db.do_maintenance_tasks()
-def value_to_str(value: Value) -> str:
- match value.kind():
- case log_db.VALUE_INT: return f"{str(value.as_int())} (int)"
- case log_db.VALUE_STRING: return f"\"{value.as_string()}\" (string)"
- case log_db.VALUE_DECIMAL: return f"{value.as_decimal()} (decimal)"
- case log_db.VALUE_BYTES: return f"{value.as_bytes()} (bytes)"
- case log_db.VALUE_NULL: return "null"
- case _: raise ValueError(f"Unsupported value kind: {value.kind()}")
+if __name__ == '__main__':
+ scheduler.init_app(app)
+ scheduler.start()
+ app.run()
diff --git a/demo/requirements.txt b/demo/requirements.txt
index b6b9bff..9d4ef16 100644
--- a/demo/requirements.txt
+++ b/demo/requirements.txt
@@ -9,3 +9,4 @@ MarkupSafe==3.0.2
python-dotenv==1.0.1
Werkzeug==3.1.3
zstandard==0.23.0
+Flask-APScheduler==1.13.1
diff --git a/demo/static/styles.css b/demo/static/styles.css
index 82bed74..bcf99dc 100644
--- a/demo/static/styles.css
+++ b/demo/static/styles.css
@@ -31,16 +31,13 @@ body {
line-height: 24px;
}
-#container {
+#messages {
+ max-height: 60vh;
+ overflow-y: scroll;
+ border: 1px solid black;
+ padding: 8px;
display: flex;
- flex-flow: row wrap;
- justify-content: space-between;
- align-items: flex-start;
-}
-
-a,
-.contrast {
- color: var(--blue);
+ flex-direction: column-reverse;
}
h1 {
@@ -77,64 +74,3 @@ input[type="submit"] {
margin-top: 20px;
}
-
-button.link,
-input[type="submit"].link {
- background: none;
- color: var(--blue);
- padding: 0;
- margin: 0;
- text-decoration: underline;
- font-family: "Inclusive Sans";
-}
-
-button.red,
-input[type="submit"].red {
- background-color: var(--red);
-}
-
-button.blue,
-input[type="submit"].blue {
- background-color: var(--blue);
-}
-
-button.yellow,
-input[type="submit"].yellow {
- background-color: var(--yellow);
-}
-
-input[type="text"],
-input[type*="date"] {
- font-size: 16px;
- color: var(--black);
-}
-
-input[type="text"]:disabled,
-input[type*="date"]:disabled {
- color: var(--gray);
-}
-
-aside {
- width: 250px;
-}
-
-main {
- flex: 1;
- overflow-y: scroll;
- max-height: 100%;
-}
-
-table {
- width: 100%;
-}
-
-th,
-td {
- min-width: 100px;
- text-align: left;
- padding: 7px 5px;
-}
-
-tr:nth-of-type(odd) td {
- background-color: var(--light-gray-2);
-}
diff --git a/demo/templates/base.html.j2 b/demo/templates/base.html.j2
index 47b3f6c..06ab5fc 100644
--- a/demo/templates/base.html.j2
+++ b/demo/templates/base.html.j2
@@ -6,14 +6,25 @@
<link rel="preload" href="/static/InclusiveSans-Regular.ttf" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="stylesheet" href="/static/styles.css"></link>
{% block head_meta %}
- <title>Database browser</title>
- <meta name="description" content="Database browser for log_db">
+ <title>Demo chat app</title>
+ <meta name="description" content="Demo chat app">
{% endblock %}
</head>
<body>
- <h1><a href="/">Database browser</a></h1>
+ <h1><a href="/">Demo chat app</a></h1>
{% block content %}{% endblock %}
+ <form id="refresh" action="/" hidden method="get" htmp replace="messages">
+ <input id="refresh_hidden" type="hidden" name="nonce" value="">
+ </form>
+
<script src="/static/htmp.js"></script>
+ <script>
+ setInterval(() => {
+ console.log('refreshing');
+ document.getElementById('refresh_hidden').value = Date.now();
+ document.getElementById('refresh').submit();
+ }, 3000);
+ </script>
</body>
</html>
diff --git a/demo/templates/frag_aside.html.j2 b/demo/templates/frag_aside.html.j2
deleted file mode 100644
index 6c5658f..0000000
--- a/demo/templates/frag_aside.html.j2
+++ /dev/null
@@ -1,27 +0,0 @@
-{#
-Params
- selected_form Form fragment to include
-#}
-
-<aside id="aside">
- <h3>Read operations</h3>
- <ul>
- {% with href="/find", text="Find" %}
- <li>{% include "frag_aside_link.html.j2" %}</li>
- {% endwith %}
- {% with href="/range", text="Range" %}
- <li>{% include "frag_aside_link.html.j2" %}</li>
- {% endwith %}
- </ul>
- <h3>Write operations</h3>
- <ul>
- {% with href="/upsert", text="Upsert" %}
- <li>{% include "frag_aside_link.html.j2" %}</li>
- {% endwith %}
- {% with href="/delete", text="Delete" %}
- <li>{% include "frag_aside_link.html.j2" %}</li>
- {% endwith %}
- </ul>
-
- {% include selected_form %}
-</aside>
diff --git a/demo/templates/frag_aside_link.html.j2 b/demo/templates/frag_aside_link.html.j2
deleted file mode 100644
index 9cd6041..0000000
--- a/demo/templates/frag_aside_link.html.j2
+++ /dev/null
@@ -1,9 +0,0 @@
-{#
-Params
- text Text to render in the link
- href URL to link to
-#}
-
-<form method="GET" action="{{ href }}" htmp replace=form_query>
- <button class="link" type="submit">{{ text }}</button>
-</form>
diff --git a/demo/templates/frag_error.html.j2 b/demo/templates/frag_error.html.j2
deleted file mode 100644
index c5034cd..0000000
--- a/demo/templates/frag_error.html.j2
+++ /dev/null
@@ -1,16 +0,0 @@
-{#
-Params
- error Error message to display
- container (optional) id for a div to wrap the error message in
-#}
-
-{% if container %}
-<div id="{{ container }}">
-{% endif %}
-
-<h2>Error</h2>
-{{ error }}
-
-{% if container %}
-</div>
-{% endif %}
diff --git a/demo/templates/frag_form_delete.html.j2 b/demo/templates/frag_form_delete.html.j2
deleted file mode 100644
index 22306c1..0000000
--- a/demo/templates/frag_form_delete.html.j2
+++ /dev/null
@@ -1,7 +0,0 @@
-<form id="form_query" action="/delete" method="post" htmp replace=results>
- <label for="field">Field</label>
- <input type="text" name="field" id="field" required>
- <label for="value">Value</label>
- <input type="text" name="value" id="value" required>
- <button type="submit">Run query</button>
-</form>
diff --git a/demo/templates/frag_form_find.html.j2 b/demo/templates/frag_form_find.html.j2
deleted file mode 100644
index cadd44b..0000000
--- a/demo/templates/frag_form_find.html.j2
+++ /dev/null
@@ -1,7 +0,0 @@
-<form id="form_query" action="/find" method="post" htmp replace=results>
- <label for="field">Field</label>
- <input type="text" name="field" id="field" required>
- <label for="values">Values (one per line)</label>
- <textarea name="values" id="values" required></textarea>
- <button type="submit">Run query</button>
-</form>
diff --git a/demo/templates/frag_form_range.html.j2 b/demo/templates/frag_form_range.html.j2
deleted file mode 100644
index 84b8024..0000000
--- a/demo/templates/frag_form_range.html.j2
+++ /dev/null
@@ -1,21 +0,0 @@
-<form id="form_query" action="/range" method="post" htmp replace=results>
- <label for="field">Field</label>
- <input type="text" name="field" id="field" required>
- <label for="from_type">Lower bound type</label>
- <select id="from_type" name="from_type" required>
- <option value="unbounded">Unbounded</option>
- <option value="included">Inclusive</option>
- <option value="excluded">Exclusive</option>
- </select>
- <label for="values">Lower bound value</label>
- <input type="text" name="from_value" id="from_value">
- <label for="to_type">Upper bound type</label>
- <select id="to_type" name="to_type" required>
- <option value="unbounded">Unbounded</option>
- <option value="included">Inclusive</option>
- <option value="excluded">Exclusive</option>
- </select>
- <label for="values">Upper bound value</label>
- <input type="text" name="to_value" id="to_value">
- <button type="submit">Run query</button>
-</form>
diff --git a/demo/templates/frag_form_upsert.html.j2 b/demo/templates/frag_form_upsert.html.j2
deleted file mode 100644
index 68820f5..0000000
--- a/demo/templates/frag_form_upsert.html.j2
+++ /dev/null
@@ -1,5 +0,0 @@
-<form id="form_query" action="/upsert" method="post" htmp replace=results>
- <label for="values">Values (one per line)</label>
- <textarea name="values" id="values" required></textarea>
- <button type="submit">Run query</button>
-</form>
diff --git a/demo/templates/frag_messages.html.j2 b/demo/templates/frag_messages.html.j2
new file mode 100644
index 0000000..0473d16
--- /dev/null
+++ b/demo/templates/frag_messages.html.j2
@@ -0,0 +1,12 @@
+{#
+Params
+ messages List of message objects to display
+#}
+
+<div id="messages">
+ {% for m in messages %}
+ <div class="message">
+ <small>{{ m.ts }}</small> <strong>{{ m.username }}</strong>: {{ m.msg }}
+ </div>
+ {% endfor %}
+</div>
diff --git a/demo/templates/frag_results.html.j2 b/demo/templates/frag_results.html.j2
deleted file mode 100644
index bfb54e9..0000000
--- a/demo/templates/frag_results.html.j2
+++ /dev/null
@@ -1,18 +0,0 @@
-<table id="results">
-<thead>
- <tr>
- {% for field_name in field_names %}
- <th>{{ field_name }}</th>
- {% endfor %}
- </tr>
-</thead>
-<tbody>
- {% for row in rows %}
- <tr>
- {% for field in row %}
- <td>{{ field }}</td>
- {% endfor %}
- </tr>
- {% endfor %}
-</tbody>
-</table>
diff --git a/demo/templates/page_error.html.j2 b/demo/templates/page_error.html.j2
index 3233283..a3f260e 100644
--- a/demo/templates/page_error.html.j2
+++ b/demo/templates/page_error.html.j2
@@ -1,5 +1,12 @@
{% extends "base.html.j2" %}
{% block content %}
-{% include "frag_error.html.j2" %}
+<div id="container">
+<h2>Error</h2>
+<p>An error occurred while processing your request.</p>
+
+<pre>
+{{ error }}
+</pre>
+</div>
{% endblock %}
diff --git a/demo/templates/page_index.html.j2 b/demo/templates/page_index.html.j2
new file mode 100644
index 0000000..2da14ab
--- /dev/null
+++ b/demo/templates/page_index.html.j2
@@ -0,0 +1,27 @@
+{% extends "base.html.j2" %}
+
+{% block content %}
+<div id="container">
+ <h2>Messages</h2>
+ {% include "frag_messages.html.j2" %}
+
+ <form id="send_form" action="/" method="post" htmp replace="messages">
+ <input required minlength="1" maxlength="20" type="text" name="username" placeholder="Username...">
+ <input required minlength="1" maxlength="140" type="text" name="message" placeholder="Message...">
+ <button type="submit">Send</button>
+ </form>
+
+ <script>
+ document.getElementById("send_form").addEventListener("submit", function(event) {
+ // add message immediately to #messages
+ const username = this.querySelector('input[name="username"]').value;
+ const message = this.querySelector('input[name="message"]').value;
+ const ts = new Date(Date.now()).toISOString().slice(0, 10);
+ const messages = document.getElementById("messages");
+ const newMessage = document.createElement("div");
+ newMessage.innerHTML = `<small>${ts}</small> <strong>${username}</strong>: ${message}`;
+ messages.prepend(newMessage);
+ });
+ </script>
+</div>
+{% endblock %}
diff --git a/demo/templates/page_main.html.j2 b/demo/templates/page_main.html.j2
deleted file mode 100644
index 907275f..0000000
--- a/demo/templates/page_main.html.j2
+++ /dev/null
@@ -1,11 +0,0 @@
-{% extends "base.html.j2" %}
-
-{% block content %}
-<div id="container">
- {% include "frag_aside.html.j2" %}
-
- <main id="main">
- {% include "frag_results.html.j2" %}
- </main>
-</div>
-{% endblock %}
diff --git a/log_db/src/common.rs b/log_db/src/common.rs
index ca9f451..0f6a058 100644
--- a/log_db/src/common.rs
+++ b/log_db/src/common.rs
@@ -564,9 +564,11 @@ impl<T> RangeBounds<T> for OwnedBounds<T> {
pub struct QueryParams {
pub offset: usize,
pub limit: usize,
+ pub sort_asc: bool,
}
pub static DEFAULT_QUERY_PARAMS: QueryParams = QueryParams {
offset: 0,
limit: usize::MAX,
+ sort_asc: true,
};
diff --git a/log_db/src/engine.rs b/log_db/src/engine.rs
index 0701191..6fb9edf 100644
--- a/log_db/src/engine.rs
+++ b/log_db/src/engine.rs
@@ -306,13 +306,19 @@ impl Engine {
tagged.extend(mapped);
}
+ if !params.sort_asc {
+ tagged.reverse();
+ }
let bound_low = params.offset;
let bound_high = (params.offset + params.limit).min(tagged.len());
let sliced = &tagged[bound_low..bound_high];
- let tagged_records = self.read_tagged_log_keys(sliced.into_iter())?;
+ let mut tagged_records = self.read_tagged_log_keys(sliced.into_iter())?;
debug!("Read {} records", tagged_records.len());
+ if !params.sort_asc {
+ tagged_records.reverse();
+ }
Ok(tagged_records)
}
@@ -420,15 +426,26 @@ impl Engine {
self.secondary_memtables[index].range(indexable_bounds)
};
- let log_key_batches: Vec<(usize, &LogKey)> =
+ let mut log_key_batches: Vec<(usize, &LogKey)> =
log_keys.into_iter().map(|log_key| (0, log_key)).collect();
+ if !params.sort_asc {
+ log_key_batches.reverse();
+ }
+
let bound_low = params.offset;
let bound_high = (params.offset + params.limit).min(log_key_batches.len());
let sliced = &log_key_batches[bound_low..bound_high];
let tagged_records = self.read_tagged_log_keys(sliced.into_iter());
- Ok(tagged_records?.into_iter().map(|(_, rec)| rec).collect())
+ let mut result_records: Vec<Row> =
+ tagged_records?.into_iter().map(|(_, rec)| rec).collect();
+
+ if !params.sort_asc {
+ result_records.reverse();
+ }
+
+ Ok(result_records)
}
/// Ensures that the `self.metadata_file` and `self.data_file` handles are still pointing to the correct files.
diff --git a/log_db/tests/integration.rs b/log_db/tests/integration.rs
index e7c6d73..2012faf 100644
--- a/log_db/tests/integration.rs
+++ b/log_db/tests/integration.rs
@@ -909,6 +909,7 @@ fn test_find_by_with_offset_and_limit() {
&QueryParams {
offset: 2,
limit: 3,
+ sort_asc: true,
},
)
.unwrap()
@@ -953,6 +954,7 @@ fn test_batch_find_by_with_offset_and_limit() {
&QueryParams {
offset: 1,
limit: 2,
+ sort_asc: true,
},
)
.unwrap()
@@ -995,6 +997,7 @@ fn test_range_by_with_offset_and_limit() {
&QueryParams {
offset: 1,
limit: 3,
+ sort_asc: true,
},
)
.unwrap()
diff --git a/py_bindings/log_db.pyi b/py_bindings/log_db.pyi
index aeb6421..9a6db62 100644
--- a/py_bindings/log_db.pyi
+++ b/py_bindings/log_db.pyi
@@ -26,11 +26,11 @@ class DB:
def configure() -> Config: ...
def upsert(self, record: Record) -> None: ...
def get(self, key: str) -> Record: ...
- def find_by(self, key: str, value: "Value", offset: int = ..., limit: int = ...) -> list[Record]: ...
- def batch_find_by(self, key: str, values: list["Value"], offset: int = ..., limit: int = ...) -> list[tuple[int, Record]]: ...
+ def find_by(self, key: str, value: "Value", offset: int = ..., limit: int = ..., sort_asc: bool = ...) -> list[Record]: ...
+ def batch_find_by(self, key: str, values: list["Value"], offset: int = ..., limit: int = ..., sort_asc: bool = ...) -> list[tuple[int, Record]]: ...
def delete(self, key: str) -> list[Record]: ...
def delete_by(self, key: str, value: "Value") -> list[Record]: ...
- def range_by(self, key: str, start: "Bound", end: "Bound", offset: int = ..., limit: int = ...) -> list[Record]: ...
+ def range_by(self, key: str, start: "Bound", end: "Bound", offset: int = ..., limit: int = ..., sort_asc: bool = ...) -> list[Record]: ...
def tx_begin(self) -> None: ...
def tx_commit(self) -> None: ...
def tx_rollback(self) -> None: ...
diff --git a/py_bindings/src/lib.rs b/py_bindings/src/lib.rs
index a4abfde..4e125ec 100644
--- a/py_bindings/src/lib.rs
+++ b/py_bindings/src/lib.rs
@@ -307,15 +307,23 @@ impl DB {
Ok(recs.map(|rec| py_from_record(rec)))
}
- #[pyo3(signature = (field, key, offset = DEFAULT_QUERY_PARAMS.offset, limit = DEFAULT_QUERY_PARAMS.limit))]
+ #[pyo3(signature = (field, key,
+ offset = DEFAULT_QUERY_PARAMS.offset,
+ limit = DEFAULT_QUERY_PARAMS.limit,
+ sort_asc = DEFAULT_QUERY_PARAMS.sort_asc))]
pub fn find_by(
&mut self,
field: PyField,
key: &Value,
offset: usize,
limit: usize,
+ sort_asc: bool,
) -> PyResult<Vec<PyRecord>> {
- let params = QueryParams { offset, limit };
+ let params = QueryParams {
+ offset,
+ limit,
+ sort_asc,
+ };
let recs = self
.db
.find_by_with_params(&field, &key.record_value, &params)
@@ -324,15 +332,23 @@ impl DB {
Ok(recs.into_iter().map(|rec| py_from_record(rec)).collect())
}
- #[pyo3(signature = (field, keys, offset = DEFAULT_QUERY_PARAMS.offset, limit = DEFAULT_QUERY_PARAMS.limit))]
+ #[pyo3(signature = (field, keys,
+ offset = DEFAULT_QUERY_PARAMS.offset,
+ limit = DEFAULT_QUERY_PARAMS.limit,
+ sort_asc = DEFAULT_QUERY_PARAMS.sort_asc))]
pub fn batch_find_by(
&mut self,
field: PyField,
keys: Vec<Value>,
offset: usize,
limit: usize,
+ sort_asc: bool,
) -> PyResult<Vec<(usize, PyRecord)>> {
- let params = QueryParams { offset, limit };
+ let params = QueryParams {
+ offset,
+ limit,
+ sort_asc,
+ };
let keys: Vec<log_db::Value> = keys.into_iter().map(|key| key.record_value).collect();
let recs = self
.db
@@ -345,7 +361,10 @@ impl DB {
.collect())
}
- #[pyo3(signature = (field, start, end, offset = DEFAULT_QUERY_PARAMS.offset, limit = DEFAULT_QUERY_PARAMS.limit))]
+ #[pyo3(signature = (field, start, end,
+ offset = DEFAULT_QUERY_PARAMS.offset,
+ limit = DEFAULT_QUERY_PARAMS.limit,
+ sort_asc = DEFAULT_QUERY_PARAMS.sort_asc))]
pub fn range_by(
&mut self,
field: PyField,
@@ -353,8 +372,13 @@ impl DB {
end: &PyRangeBound,
offset: usize,
limit: usize,
+ sort_asc: bool,
) -> PyResult<Vec<PyRecord>> {
- let params = QueryParams { offset, limit };
+ let params = QueryParams {
+ offset,
+ limit,
+ sort_asc,
+ };
let range = OwnedBounds::new(
match start {
PyRangeBound::Unbounded() => StdBound::Unbounded,