{"id":50094820,"url":"https://github.com/redis-developer/sql-redis","last_synced_at":"2026-05-23T02:35:54.411Z","repository":{"id":356604848,"uuid":"1121998664","full_name":"redis-developer/sql-redis","owner":"redis-developer","description":"SQL to Redis command translation utility","archived":false,"fork":false,"pushed_at":"2026-05-08T19:57:41.000Z","size":551,"stargazers_count":8,"open_issues_count":1,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-08T21:36:49.721Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/redis-developer.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2025-12-23T23:37:53.000Z","updated_at":"2026-05-08T19:57:46.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/redis-developer/sql-redis","commit_stats":null,"previous_names":["redis-developer/sql-redis"],"tags_count":8,"template":false,"template_full_name":null,"purl":"pkg:github/redis-developer/sql-redis","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/redis-developer%2Fsql-redis","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/redis-developer%2Fsql-redis/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/redis-developer%2Fsql-redis/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/redis-developer%2Fsql-redis/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/redis-developer","download_url":"https://codeload.github.com/redis-developer/sql-redis/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/redis-developer%2Fsql-redis/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33380804,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-23T01:21:08.577Z","status":"online","status_checked_at":"2026-05-23T02:00:05.530Z","response_time":53,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-05-23T02:35:49.622Z","updated_at":"2026-05-23T02:35:54.398Z","avatar_url":"https://github.com/redis-developer.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n    \u003cimg width=\"300\" src=\"https://raw.githubusercontent.com/redis/redis-vl-python/main/docs/_static/Redis_Logo_Red_RGB.svg\" alt=\"Redis\"\u003e\n    \u003ch1\u003esql-redis\u003c/h1\u003e\n    \u003cp\u003e\u003cstrong\u003eSQL on top of RediSearch and RedisVL indexes\u003c/strong\u003e\u003c/p\u003e\n\u003c/div\u003e\n\n\u003cdiv align=\"center\"\u003e\n\n**[Documentation](https://redis-developer.github.io/sql-redis/)** • **[PyPI](https://pypi.org/project/sql-redis/)**\n\n\u003c/div\u003e\n\n---\n\nA SQL-to-Redis translator that converts SQL `SELECT` statements into Redis `FT.SEARCH` and `FT.AGGREGATE` commands. Query Redis collections with familiar SQL on top of RediSearch and RedisVL indexes.\n\n## Install\n\n```bash\npip install sql-redis\n```\n\n## Quick example\n\n```python\nfrom redis import Redis\nfrom sql_redis import create_executor\n\nclient = Redis()\nexecutor = create_executor(client)        # lazy schema loading; no I/O yet\n\n# Simple query\nresult = executor.execute(\"\"\"\n    SELECT title, price\n    FROM products\n    WHERE category = 'electronics' AND price \u003c 500\n    ORDER BY price ASC\n    LIMIT 10\n\"\"\")\n\nfor row in result.rows:\n    print(row[b\"title\"], row[b\"price\"])\n\n# Vector search with parameter substitution\nresult = executor.execute(\n    \"\"\"\n    SELECT title, vector_distance(embedding, :vec) AS score\n    FROM products\n    LIMIT 5\n    \"\"\",\n    params={\"vec\": vector_bytes},\n)\n```\n\nPass `decode_responses=True` to the `Redis` client if you want string keys instead of bytes.\n\n## What's implemented\n\n- [x] Basic `SELECT` with field selection\n- [x] `WHERE` with TEXT, NUMERIC, TAG, GEO field types\n- [x] Comparison operators: `=`, `!=`, `\u003c`, `\u003c=`, `\u003e`, `\u003e=`, `BETWEEN`, `IN`\n- [x] Boolean operators: `AND`, `OR`, `NOT`\n- [x] Aggregations: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`\n- [x] `GROUP BY` with multiple aggregations\n- [x] `ORDER BY` with `ASC`/`DESC`\n- [x] `LIMIT` and `OFFSET` pagination\n- [x] Computed fields: `price * 0.9 AS discounted`\n- [x] Vector KNN search: `vector_distance(field, :param)`\n- [x] Hybrid search (filters + vector)\n- [x] Full-text search: exact phrase, fuzzy, proximity, OR/union, LIKE patterns, BM25 scoring\n- [x] GEO field queries with full operator support\n- [x] Date functions: `YEAR()`, `MONTH()`, `DAY()`, `DATE_FORMAT()`, etc.\n- [x] `IS NULL` / `IS NOT NULL` via `ismissing()` (requires Redis 7.4+)\n- [x] `exists()` function for field presence checks\n\n## What's not implemented (yet)\n\n- [ ] JOINs (Redis doesn't support cross-index joins)\n- [ ] Subqueries\n- [ ] HAVING clause\n- [ ] DISTINCT\n- [ ] Index creation from SQL (`CREATE INDEX`)\n\nThe translator raises `ValueError` for unsupported clauses; do not retry with rephrasing.\n\n## How-to guides\n\nThe next sections are task-oriented recipes. Each shows the SQL syntax, the RediSearch command produced, and any gotchas.\n\n- [TEXT search](#text-search)\n- [`IS NULL` / `IS NOT NULL`](#is-null--is-not-null-ismissing)\n- [`exists()` field presence](#exists--field-presence-check)\n- [DATE / DATETIME handling](#datedatetime-handling)\n- [Date functions](#date-functions)\n- [GEO field support](#geo-field-support)\n\n### TEXT search\n\nFull-text search on TEXT fields with multiple search modes:\n\n| Feature | SQL Syntax | RediSearch Output | Notes |\n|---------|-----------|-------------------|-------|\n| Exact phrase | `title = 'gaming laptop'` | `@title:\"gaming laptop\"` | Stopwords stripped |\n| Tokenized search | `fulltext(title, 'gaming laptop')` | `@title:(gaming laptop)` | Stopwords stripped |\n| Fuzzy LD=1 | `fuzzy(title, 'laptap')` | `@title:%laptap%` | |\n| Fuzzy LD=2 | `fuzzy(title, 'laptap', 2)` | `@title:%%laptap%%` | |\n| Fuzzy LD=3 | `fuzzy(title, 'laptap', 3)` | `@title:%%%laptap%%%` | |\n| OR / union | `fulltext(title, 'laptop OR tablet')` | `@title:(laptop\\|tablet)` | |\n| Prefix | `title LIKE 'lap%'` | `@title:lap*` | |\n| Suffix | `title LIKE '%top'` | `@title:*top` | |\n| Contains | `title LIKE '%apt%'` | `@title:*apt*` | |\n| Proximity (slop) | `fulltext(title, 'gaming laptop', 2)` | `@title:(gaming laptop) =\u003e { $slop: 2; }` | |\n| Proximity + order | `fulltext(title, 'gaming laptop', 2, true)` | `@title:(gaming laptop) =\u003e { $slop: 2; $inorder: true; }` | |\n| Optional term | `fulltext(title, 'laptop ~gaming')` | `@title:(laptop ~gaming)` | |\n| BM25 score | `SELECT score() AS relevance FROM idx` | `FT.SEARCH ... WITHSCORES` | |\n| Negation | `NOT fulltext(title, 'refurbished')` | `-@title:refurbished` | |\n\n**Examples:**\n\n```sql\n-- Exact phrase match (stopwords like \"of\" are stripped automatically)\nSELECT * FROM products WHERE title = 'bank of america'\n-- Produces: @title:\"bank america\"\n\n-- Fuzzy search for typos (Levenshtein distance 2)\nSELECT * FROM products WHERE fuzzy(title, 'laptap', 2)\n\n-- OR search across terms\nSELECT * FROM products WHERE fulltext(title, 'laptop OR tablet OR phone')\n\n-- Proximity: terms within 3 words of each other, in order\nSELECT * FROM products WHERE fulltext(title, 'gaming laptop', 3, true)\n\n-- Suffix/contains pattern matching\nSELECT * FROM products WHERE title LIKE '%phone%'\n\n-- BM25 relevance scoring\nSELECT title, score() AS relevance FROM products WHERE fulltext(title, 'laptop')\n\n-- Multi-field search\nSELECT * FROM products WHERE fulltext(title, 'laptop') OR fulltext(description, 'laptop')\n```\n\n**Stopword handling:**\n\nBoth `=` (exact phrase) and `fulltext()` (tokenized search) automatically strip [Redis default stopwords](https://redis.io/docs/latest/develop/ai/search-and-query/advanced-concepts/stopwords/) before sending queries to RediSearch. This is necessary because RediSearch does not index stopwords, so including them in queries causes syntax errors or failed matches. A `UserWarning` is emitted when stopwords are removed.\n\nFor example, `WHERE title = 'bank of america'` produces `@title:\"bank america\"` because \"of\" is a default stopword and is never stored in the inverted index. The stripped phrase still matches correctly because the indexer assigns consecutive token positions after dropping stopwords.\n\nTo include stopwords in your queries, create your index with `STOPWORDS 0`:\n\n```\nFT.CREATE myindex ON HASH PREFIX 1 doc: STOPWORDS 0 SCHEMA title TEXT\n```\n\n**Notes:**\n- `=` on TEXT fields performs **exact phrase** matching (double-quoted)\n- `fulltext()` performs **tokenized** AND search (parenthesized)\n- Both operators strip stopwords and emit a warning when they do\n- `fuzzy()` and `fulltext()` only work on TEXT fields; using them on TAG or NUMERIC raises `ValueError`\n- OR must be **uppercase**: `'laptop OR tablet'` triggers union; lowercase `'laptop or tablet'` is treated as a regular three-word AND search\n- Special characters (`@`, `|`, `-`, `*`, `+`, etc.) in search terms are automatically escaped\n\n### IS NULL / IS NOT NULL (ismissing)\n\nCheck for missing (absent) fields using standard SQL `IS NULL` / `IS NOT NULL` syntax. Requires **Redis 7.4+** (RediSearch 2.10+) with `INDEXMISSING` declared on the field.\n\n| SQL | RediSearch Output |\n|-----|-------------------|\n| `WHERE email IS NULL` | `ismissing(@email)` |\n| `WHERE email IS NOT NULL` | `-ismissing(@email)` |\n\n```sql\n-- Find users without an email\nSELECT * FROM users WHERE email IS NULL\n\n-- Find users with an email\nSELECT * FROM users WHERE email IS NOT NULL\n\n-- Combine with other filters\nSELECT * FROM users WHERE category = 'eng' AND email IS NULL\n```\n\n**Note:** The field must be declared with `INDEXMISSING` in the index schema. A warning is emitted at translation time as a reminder.\n\n### exists() — Field presence check\n\nCheck whether a field has a value using `exists()` in SELECT or HAVING. This uses `FT.AGGREGATE` with `APPLY exists(@field)`.\n\n```sql\n-- Check if fields exist (returns 1 or 0)\nSELECT name, exists(email) AS has_email FROM users\n\n-- Filter to only rows where a field exists\nSELECT name FROM users HAVING exists(email) = 1\n\n-- Combine with other computed fields\nSELECT name, exists(email) AS has_email, exists(phone) AS has_phone FROM users\n```\n\n**Note:** `exists()` is different from `IS NOT NULL` — it works via `FT.AGGREGATE APPLY` and doesn't require `INDEXMISSING` on the field, but returns `1`/`0` rather than filtering rows directly.\n\n### DATE/DATETIME handling\n\nRedis does not have a native DATE field type. Dates are stored as **NUMERIC fields** with Unix timestamps.\n\n**sql-redis automatically converts ISO 8601 date literals to Unix timestamps:**\n\n```sql\n-- Date literal (automatically converted to timestamp 1704067200)\nSELECT * FROM events WHERE created_at \u003e '2024-01-01'\n\n-- Datetime literal with time\nSELECT * FROM events WHERE created_at \u003e '2024-01-01T12:00:00'\n\n-- Date range with BETWEEN\nSELECT * FROM events WHERE created_at BETWEEN '2024-01-01' AND '2024-01-31'\n\n-- Multiple date conditions\nSELECT * FROM events WHERE created_at \u003e '2024-01-01' AND created_at \u003c '2024-12-31'\n```\n\n**Supported date formats:**\n- Date: `'2024-01-01'` (interpreted as midnight UTC)\n- Datetime: `'2024-01-01T12:00:00'` or `'2024-01-01 12:00:00'`\n- Datetime with timezone: `'2024-01-01T12:00:00Z'`, `'2024-01-01T12:00:00+00:00'`\n\n**Note:** All dates without timezone are interpreted as UTC. You can also use raw Unix timestamps if preferred:\n\n```sql\nSELECT * FROM events WHERE created_at \u003e 1704067200\n```\n\n### Date functions\n\nExtract date parts using SQL functions that map to Redis `APPLY` expressions:\n\n| SQL Function | Redis Function | Description |\n|--------------|----------------|-------------|\n| `YEAR(field)` | `year(@field)` | Extract year (e.g., 2024) |\n| `MONTH(field)` | `monthofyear(@field)` | Extract month (0-11) |\n| `DAY(field)` | `dayofmonth(@field)` | Extract day of month (1-31) |\n| `HOUR(field)` | `hour(@field)` | Round to hour |\n| `MINUTE(field)` | `minute(@field)` | Round to minute |\n| `DAYOFWEEK(field)` | `dayofweek(@field)` | Day of week (0=Sunday) |\n| `DAYOFYEAR(field)` | `dayofyear(@field)` | Day of year (0-365) |\n| `DATE_FORMAT(field, fmt)` | `timefmt(@field, fmt)` | Format timestamp |\n\n**Examples:**\n\n```sql\n-- Extract year and month\nSELECT name, YEAR(created_at) AS year, MONTH(created_at) AS month FROM events\n\n-- Filter by year\nSELECT name FROM events WHERE YEAR(created_at) = 2024\n\n-- Group by date parts\nSELECT YEAR(created_at) AS year, COUNT(*) FROM events GROUP BY year\n\n-- Format dates\nSELECT name, DATE_FORMAT(created_at, '%Y-%m-%d') AS date FROM events\n```\n\n**Note:** Redis's `monthofyear()` returns 0-11 (not 1-12), and `dayofweek()` returns 0 for Sunday.\n\n**Limitations:**\n- `NOT YEAR(field) = 2024` is not supported (raises `ValueError`)\n- `DATE_FORMAT()` is only supported in SELECT, not in WHERE (raises `ValueError`)\n- Date functions combined with `OR` are not supported (raises `ValueError`)\n\n### GEO field support\n\nGEO fields are fully implemented with standard SQL-like syntax:\n\n| Feature | Status |\n|---------|--------|\n| Coordinate order | `POINT(lon, lat)` — matches Redis native format |\n| Default unit | Meters (`m`) — SQL standard |\n| All operators | `\u003c`, `\u003c=`, `\u003e`, `\u003e=`, `BETWEEN` |\n| Distance calculation | `geo_distance()` in SELECT clause |\n| Combined filters | GEO + TEXT/TAG/NUMERIC |\n\n**Coordinate order: `POINT(lon, lat)`**\n\nUse **longitude first**, matching Redis's native GEO format:\n\n```sql\n-- San Francisco coordinates: lon=-122.4194, lat=37.7749\nSELECT name FROM stores WHERE geo_distance(location, POINT(-122.4194, 37.7749)) \u003c 5000\n```\n\n**Units:**\n\n| Unit | Code | Example |\n|------|------|---------|\n| Meters | `m` | `geo_distance(location, POINT(-122.4194, 37.7749)) \u003c 5000` |\n| Kilometers | `km` | `geo_distance(location, POINT(-122.4194, 37.7749), 'km') \u003c 5` |\n| Miles | `mi` | `geo_distance(location, POINT(-122.4194, 37.7749), 'mi') \u003c 3` |\n| Feet | `ft` | `geo_distance(location, POINT(-122.4194, 37.7749), 'ft') \u003c 16400` |\n\nDefault is meters when no unit is specified.\n\n**Operators:**\n\n```sql\n-- Less than (uses optimized GEOFILTER)\nSELECT name FROM stores WHERE geo_distance(location, POINT(-122.4194, 37.7749)) \u003c 5000\n\n-- Less than or equal (uses optimized GEOFILTER)\nSELECT name FROM stores WHERE geo_distance(location, POINT(-122.4194, 37.7749)) \u003c= 5000\n\n-- Greater than (uses FT.AGGREGATE with FILTER)\nSELECT name FROM stores WHERE geo_distance(location, POINT(-122.4194, 37.7749)) \u003e 100000\n\n-- Greater than or equal (uses FT.AGGREGATE with FILTER)\nSELECT name FROM stores WHERE geo_distance(location, POINT(-122.4194, 37.7749)) \u003e= 100000\n\n-- Between (uses FT.AGGREGATE with FILTER)\nSELECT name FROM stores WHERE geo_distance(location, POINT(-122.4194, 37.7749), 'km') BETWEEN 10 AND 100\n```\n\n**Distance calculation in SELECT:**\n\n```sql\n-- Get distance to each store (returns meters)\nSELECT name, geo_distance(location, POINT(-122.4194, 37.7749)) AS distance\nFROM stores\n\n-- With explicit unit\nSELECT name, geo_distance(location, POINT(-122.4194, 37.7749), 'km') AS distance_km\nFROM stores\n```\n\n**Combined filters:**\n\n```sql\n-- GEO + TAG filter\nSELECT name FROM stores\nWHERE category = 'retail' AND geo_distance(location, POINT(-122.4194, 37.7749)) \u003c 5000\n\n-- GEO + NUMERIC filter\nSELECT name FROM stores\nWHERE rating \u003e= 4.0 AND geo_distance(location, POINT(-122.4194, 37.7749), 'mi') \u003c 10\n\n-- GEO + TEXT filter\nSELECT name FROM stores\nWHERE name = 'Downtown' AND geo_distance(location, POINT(-122.4194, 37.7749)) \u003c 10000\n```\n\n## Concepts and design\n\nThe Diataxis \"explanation\" tier: why the library is shaped the way it is. The full versions live under [`docs/concepts/`](docs/concepts/).\n\n### Why SQL instead of a pandas-like Python DSL?\n\n| Approach | Example | Trade-offs |\n|----------|---------|------------|\n| **SQL** | `SELECT * FROM products WHERE price \u003e 100` | Universal, well-understood, tooling exists |\n| **Pandas-like** | `df[df.price \u003e 100]` | Pythonic but limited to Python, no standard |\n| **Builder pattern** | `query.select(\"*\").where(price__gt=100)` | Type-safe but verbose, learning curve |\n\nWe chose SQL because:\n\n1. **Universality** — SQL is the lingua franca of data. Developers, analysts, and tools all speak it.\n2. **No new DSL to learn** — Users already know SQL. A pandas-like API requires learning our specific dialect.\n3. **Tooling compatibility** — SQL strings can be generated by ORMs, query builders, or AI assistants.\n4. **Clear mapping** — SQL semantics map reasonably well to RediSearch operations (SELECT→LOAD, WHERE→filter, GROUP BY→GROUPBY).\n\nThe downside is losing Python's type checking and IDE support, but for a query interface, the universality trade-off is worth it.\n\n### Why sqlglot instead of writing a custom parser?\n\nOptions considered: custom parser (regex / hand-rolled recursive descent), PLY/Lark (parser generators), sqlparse (tokenizer only), and sqlglot (production SQL parser). We chose sqlglot because:\n\n1. **Battle-tested** — Used in production by companies like Tobiko (SQLMesh). Handles edge cases we'd miss.\n2. **Full AST** — Provides a complete abstract syntax tree, not just tokens. We can traverse and analyze queries properly.\n3. **Dialect support** — Handles SQL variations. Users can write MySQL-style or PostgreSQL-style queries.\n4. **Active maintenance** — Regular releases, responsive maintainers, good documentation.\n\nWriting a custom parser would be error-prone and time-consuming for a POC. sqlglot lets us focus on the translation logic rather than parsing edge cases.\n\n### Why schema-aware translation?\n\nRedis field types determine query syntax:\n\n| Field Type | Redis Syntax | Example |\n|------------|--------------|---------|\n| TEXT | `@field:term` | `@title:laptop` |\n| NUMERIC | `@field:[min max]` | `@price:[100 500]` |\n| TAG | `@field:{value}` | `@category:{books}` |\n\nWithout schema knowledge, we can't translate `category = 'books'` correctly — it could be `@category:books` (TEXT search) or `@category:{books}` (TAG exact match). The `SchemaRegistry` fetches index schemas via `FT.INFO` and the translator uses this to generate correct syntax per field type. This adds a Redis round-trip at initialization but ensures correct query generation.\n\n### Architecture\n\n```\nSQL String\n    ↓\n┌─────────────────┐\n│   SQLParser     │  Parse SQL → ParsedQuery dataclass\n└────────┬────────┘\n         ↓\n┌─────────────────┐\n│ SchemaRegistry  │  Load field types from Redis\n└────────┬────────┘\n         ↓\n┌─────────────────┐\n│    Analyzer     │  Classify conditions by field type\n└────────┬────────┘\n         ↓\n┌─────────────────┐\n│  QueryBuilder   │  Generate RediSearch syntax per type\n└────────┬────────┘\n         ↓\n┌─────────────────┐\n│   Translator    │  Orchestrate pipeline, build command\n└────────┬────────┘\n         ↓\n┌─────────────────┐\n│    Executor     │  Execute command, parse results\n└────────┬────────┘\n         ↓\nQueryResult(rows, count)\n```\n\nEach layer has focused unit tests; 100% coverage is achievable because responsibilities are clear. Adding a new field type (e.g., GEO) means updating Analyzer and QueryBuilder, not rewriting everything. Early prototypes combined parsing and translation, which led to tests that required Redis connections for simple SQL parsing tests, difficulty testing edge cases in isolation, and tangled code that was hard to modify. The layered approach emerged from TDD — writing tests first revealed natural boundaries.\n\n## For AI agents\n\n- **[`AGENTS.md`](AGENTS.md):** how to use sql-redis from an agent, including gotchas and the error model.\n- **[`llms.txt`](https://redis-developer.github.io/sql-redis/llms.txt):** auto-generated flat index of every doc page with one-line summaries (built by `mkdocs-llmstxt`).\n- **[`docs/for-ais-only/`](docs/for-ais-only/):** repository map, build and test guide, and intentional failure modes for agents modifying the library.\n\n## Development\n\n```bash\nmake install       # uv sync --all-extras\nmake test          # requires Docker for testcontainers\nmake test-cov      # with coverage report\nmake lint          # format + mypy\nmake docs-serve    # uv sync --group docs \u0026\u0026 preview at http://localhost:8000\n```\n\n## Testing philosophy\n\nThis project uses strict TDD with 100% test coverage as a hard requirement:\n\n1. **Write failing tests first** — Define expected behavior before implementation.\n2. **One test at a time** — Implement just enough to pass each test.\n3. **No untestable code** — If we can't test it, we don't write it.\n4. **Integration tests mirror raw Redis** — `test_sql_queries.py` verifies SQL produces the same results as equivalent `FT.AGGREGATE` commands in `test_redis_queries.py`.\n\nCoverage is enforced in CI. Pragmas (`# pragma: no cover`) are forbidden — if code can't be tested, it shouldn't exist. See [`docs/concepts/testing-philosophy.md`](docs/concepts/testing-philosophy.md) for the long form.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fredis-developer%2Fsql-redis","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fredis-developer%2Fsql-redis","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fredis-developer%2Fsql-redis/lists"}