Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,8 @@ from a live database by `/internal/goldeneye`, a nested module, and its tests
verify the committed files against one byte for byte. The same module checks
the `analyze_*` cases under `/internal/endtoend/testdata/` against what the
database itself reports for them, so a `fixture.sql` next to a case's schema
gives the queries rows to run against. Engines whose database is not
available skip.
gives the queries rows to run against. ClickHouse and SQLite have the check
today; engines whose database is not available skip.

```bash
cd internal/goldeneye
Expand Down
21 changes: 20 additions & 1 deletion internal/core/analyzer/expr.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,12 @@ func (a *analyzer) typeAExpr(e *ast.A_Expr) (exprType, error) {
if err != nil {
return exprType{}, err
}
// The postfix null tests SQLite has — x ISNULL, x NOTNULL, x NOT NULL —
// arrive as operators with no right operand, and are predicates that
// are never NULL.
if e.Rexpr == nil && isNullTest(opName) {
return a.boolType(false)
}
rightT, err := a.typeExpr(e.Rexpr)
if err != nil {
return exprType{}, err
Expand All @@ -305,12 +311,25 @@ func (a *analyzer) typeAExpr(e *ast.A_Expr) (exprType, error) {
if err != nil {
return exprType{}, err
}
// An operator's result is NULL when an operand is, except for IS and
// IS NOT, which test for NULL rather than propagate it: x IS NULL and
// x IS y are never NULL, whatever x and y are.
return exprType{
typeOID: overload.ResultTypeOID,
nullable: leftT.nullable || rightT.nullable,
nullable: (leftT.nullable || rightT.nullable) && !isNullTest(opName),
}, nil
}

// isNullTest reports whether an operator compares with NULL as a value
// rather than propagating it, so that its result is never NULL.
func isNullTest(opName string) bool {
switch opName {
case "IS", "IS NOT", "ISNULL", "NOTNULL", "NOT NULL":
return true
}
return false
}

// typeQuantifiedExpr types "x = ANY($1)" and "x > ALL(...)": the right side
// holds values of the left side's type, and the result is a predicate.
func (a *analyzer) typeQuantifiedExpr(e *ast.A_Expr) (exprType, error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"command": "analyze",
"args": ["--dialect", "sqlite", "--schema", "schema.sql", "query.sql"],
"contexts": ["base"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
INSERT INTO users (id, name, bio, score) VALUES (1, 'ann', NULL, 1.5);
INSERT INTO users (id, name, bio, score) VALUES (2, 'bob', 'hello', 2.5);
INSERT INTO posts (id, user_id, title, created) VALUES (1, 1, 'first', '2024-01-01');
INSERT INTO posts (id, user_id, title, created) VALUES (2, 1, NULL, '2024-01-02');
23 changes: 23 additions & 0 deletions internal/endtoend/testdata/analyze_expressions/sqlite/query.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- name: PostStats :one
SELECT count(*) AS total, max(id) AS latest, min(created) AS first
FROM posts WHERE user_id = ?;

-- name: UserScores :one
SELECT avg(score) AS mean, sum(score) AS sum, sum(id) AS ids, group_concat(name) AS names
FROM users;

-- name: ListUsers :many
SELECT id, lower(name) AS lname, id + 1 AS next
FROM users WHERE id IN (?, ?);

-- name: ListPosts :many
SELECT id, title FROM posts ORDER BY created LIMIT ? OFFSET ?;

-- name: UserPosts :many
SELECT u.name, p.title, p.created
FROM users u LEFT JOIN posts p ON p.user_id = u.id
WHERE u.name LIKE ? || '%';

-- name: CreatePost :one
INSERT INTO posts (user_id, title, created) VALUES (?, ?, datetime('now'))
RETURNING id, created;
15 changes: 15 additions & 0 deletions internal/endtoend/testdata/analyze_expressions/sqlite/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
bio TEXT,
score REAL NOT NULL DEFAULT 0
);

CREATE TABLE posts (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
title TEXT,
created TEXT NOT NULL
);

CREATE INDEX posts_user ON posts(user_id);
245 changes: 245 additions & 0 deletions internal/endtoend/testdata/analyze_expressions/sqlite/stdout.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
[
{
"name": "PostStats",
"cmd": ":one",
"columns": [
{
"name": "total",
"type": {
"name": "integer"
}
},
{
"name": "latest",
"type": {
"name": "integer",
"nullable": true
}
},
{
"name": "first",
"type": {
"name": "text",
"nullable": true
}
}
],
"params": [
{
"number": 1,
"column": {
"name": "user_id",
"type": {
"name": "integer"
},
"table": "posts"
}
}
]
},
{
"name": "UserScores",
"cmd": ":one",
"columns": [
{
"name": "mean",
"type": {
"name": "real",
"nullable": true
}
},
{
"name": "sum",
"type": {
"name": "real",
"nullable": true
}
},
{
"name": "ids",
"type": {
"name": "integer",
"nullable": true
}
},
{
"name": "names",
"type": {
"name": "text",
"nullable": true
}
}
],
"params": []
},
{
"name": "ListUsers",
"cmd": ":many",
"columns": [
{
"name": "id",
"type": {
"name": "integer"
},
"table": "users"
},
{
"name": "lname",
"type": {
"name": "text"
}
},
{
"name": "next",
"type": {
"name": "integer"
}
}
],
"params": [
{
"number": 1,
"column": {
"name": "id",
"type": {
"name": "integer"
},
"table": "users"
}
},
{
"number": 2,
"column": {
"name": "id",
"type": {
"name": "integer"
},
"table": "users"
}
}
]
},
{
"name": "ListPosts",
"cmd": ":many",
"columns": [
{
"name": "id",
"type": {
"name": "integer"
},
"table": "posts"
},
{
"name": "title",
"type": {
"name": "text",
"nullable": true
},
"table": "posts"
}
],
"params": [
{
"number": 1,
"column": {
"name": "",
"type": {
"name": "integer"
}
}
},
{
"number": 2,
"column": {
"name": "",
"type": {
"name": "integer"
}
}
}
]
},
{
"name": "UserPosts",
"cmd": ":many",
"columns": [
{
"name": "name",
"type": {
"name": "text"
},
"table": "users"
},
{
"name": "title",
"type": {
"name": "text",
"nullable": true
},
"table": "posts"
},
{
"name": "created",
"type": {
"name": "text"
},
"table": "posts"
}
],
"params": [
{
"number": 1,
"column": {
"name": "",
"type": {
"name": "text"
}
}
}
]
},
{
"name": "CreatePost",
"cmd": ":one",
"columns": [
{
"name": "id",
"type": {
"name": "integer"
},
"table": "posts"
},
{
"name": "created",
"type": {
"name": "text"
},
"table": "posts"
}
],
"params": [
{
"number": 1,
"column": {
"name": "user_id",
"type": {
"name": "integer"
},
"table": "posts"
}
},
{
"number": 2,
"column": {
"name": "title",
"type": {
"name": "text",
"nullable": true
},
"table": "posts"
}
}
]
}
]
2 changes: 1 addition & 1 deletion internal/engine/sqlite/dialect/dialect.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"bool": "boolean"
},
"bool": "boolean",
"comparison": ["=", "==", "<>", "!=", "<", "<=", ">", ">=", "IS", "IS NOT"],
"comparison": ["=", "==", "<>", "!=", "<", "<=", ">", ">=", "IS", "IS NOT", "LIKE", "NOT LIKE", "GLOB", "NOT GLOB", "REGEXP", "NOT REGEXP", "MATCH", "NOT MATCH"],
"comparison_categories": "BNSDU",
"arithmetic": ["+", "-", "*", "/", "%"],
"arithmetic_categories": "N",
Expand Down
Loading
Loading