Skip to content

release: v0.20.0 - #65

Merged
kolkov merged 65 commits into
mainfrom
fix/v0.20.0
Oct 27, 2026
Merged

kolkov merged 65 commits into
mainfrom
fix/v0.20.0

Conversation

@kolkov

@kolkov kolkov commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

Release v0.20.0: breaking fixes for every place where Relica returned a different result than the code asked for, with no error. Each case now does what the code says or fails before anything is sent. Full list: CHANGELOG [0.20.0]; upgrade path: docs/MIGRATION_GUIDE.md › v0.19.x → v0.20.0.

Behaviour changes

  • Upsert needs exactly one conflict action: DoUpdate(cols...), the new DoUpdateAll() or DoNothing(). MySQL DoNothing() keeps the existing row. New WithMySQLUpsertSyntax.
  • Names: snake_case singular tables and snake_case untagged columns, as in ozzo-dbx; WithNaming. Embedded structs are flattened on write; an embedded single value (time.Time, sql.NullString, a Scanner/Valuer) is one column.
  • Scanning: ScanRequireFields by default (ScanLenient, ScanStrict, WithScanMode). One/All into a single value is an error.
  • UPDATE/DELETE need Where or the explicit AllRows(). Row locks work only inside a transaction.
  • Set operations read left to right: OrderBy/Limit after Union apply to the combined result, and a.Union(b).Intersect(c) means the same on every database.
  • Count()/Exists() follow what All returns with GroupBy, Distinct, set operations and CTEs.
  • SQLite identifiers are quoted with backticks, so a misspelled column fails instead of being compared as a string.
  • OrderBy takes "column [ASC|DESC]"; expressions go to OrderByExpr.
  • Removed: NewDB (use Open) and Query.QueryParams() (use Params()).

Also: 16 fixes (see CHANGELOG › Fixed), docs corrected against the code with tests that guard links, exported names and SelectExpr examples, and the agent skill skills/relica/ with audit.sh and its fixture tests.

Verification (local, before this PR)

  • go build, go vet, gofmt -l, go test ./..., golangci-lint (root and test/ module): clean
  • Cross builds: linux/amd64, darwin/arm64
  • Integration suite on real databases: PostgreSQL 449 PASS, MySQL 398 PASS, 0 FAIL; SQLite PASS
  • skills/relica/scripts/audit_test.sh: PASS with gawk and mawk

kolkov added 30 commits October 18, 2026 21:35
…nal error comparison

- AGENTS.md SQLite: mode=ro works only in a file: URI. modernc.org/sqlite and
  mattn/go-sqlite3 cut the query off a plain path, so the reader opens read-write
  and, with _txlock=immediate, waits behind the writer (database is locked).
  On a real mode=ro connection SQLite runs BEGIN IMMEDIATE as a read transaction.
- AGENTS.md String Operators: EscapeChars() with no arguments turns escaping off.
- MIGRATION_GUIDE v0.19.1: compare Transactional errors with errors.Is, not ==.
- TROUBLESHOOTING: readers that hit database is locked.
A bare INDEX.md rule matched in every directory and hid docs/INDEX.md.
Private dirs are ignored by directory only.
Upsert and BatchInsert with OnConflict take exactly one of DoUpdate(cols...),
DoUpdateAll() or DoNothing(). No action, two actions, an empty DoUpdate, a
DoUpdate column that is not inserted and PostgreSQL/SQLite DoUpdate without a
target are build errors; nothing is sent. Before, they ran as a plain INSERT,
updated every column, or failed with a syntax error at run time.

- DoUpdateAll: every inserted column except the target, on every dialect
- MySQL DoNothing renders ON DUPLICATE KEY UPDATE c = c instead of no clause
- WithMySQLUpsertSyntax(MySQLUpsertRowAlias) for the 8.0.19+ row alias
- Model Upsert honours Exclude (insert and update) and rejects unknown names
- dialects render a validated Upsert description; validation lives in core

FIX-010 rows 1, 2, 4, 5, 7, 8, 9.
…ssing columns (ADR-018)

Tables without TableName() are snake_case singular (UserProfile -> user_profile;
was lower case + s). Untagged fields are snake_case columns (was the Go name).
TableName() with a pointer receiver works on value models. Embedded structs
are flattened on write as they were on read. Legacy db:"pk" takes its column
from the naming rule instead of writing a column named pk.

One struct description (fields, embedded paths, columns, primary key) serves
write, read, PK and UpdateChanged, cached per DB. Naming collisions, derived
names over 63 bytes and refused names are errors.

Scanning into a struct now fails when a field has no result column
(ScanRequireFields, default) unless the query lists its columns; ScanLenient
and ScanStrict per DB or per query.

New: WithNaming, Naming, Snake, SnakeCase, SplitAcronyms, Initialisms,
GoInitialisms, TablePrefix, PluralTables, ScanMode, WithScanMode,
SelectQuery.ScanMode, Query.ScanMode. WrapDB takes options (FEAT-013).
Removed dead internal GetTableName, DefaultFieldMapFunc and the reflection
helpers they duplicated.

FIX-011, FEAT-013.
ForUpdate, ForShare and ForUpdateSkipLocked outside a transaction are build
errors on every dialect: on *DB the lock ended with the statement, so two
SKIP LOCKED workers could claim one row. A lock with UNION/INTERSECT/EXCEPT,
GROUP BY, DISTINCT, Count() or Exists() is an error too (PostgreSQL rejects
them; Count/Exists dropped the lock). SQLite is detected by dialect, not by
driver name. Integration tests prove the locks on PostgreSQL and MySQL.
…rs (ADR-020)

db.Delete("t").Execute() deleted every row; it now fails before anything is
sent. The whole table needs the explicit AllRows() on UpdateQuery and
DeleteQuery (AllRows with Where is an error). A condition that renders empty
(And(), Or(), HashExp{}, Like without values, "") dropped the WHERE clause
and hit every row; it is an error in UPDATE/DELETE, through Where, AndWhere
and OrWhere. SELECT keeps the old meaning.
Model Insert(fields...), Update(fields...) and Exclude(...) skipped names
that are not columns of the model; BatchInsert ValuesMap dropped keys that
are not batch columns; an autoid gen= with no registered generator fell
back to UUID v7. Each is now an error and nothing is sent.

Un-skips the ValuesMap integration test (AUDIT-001 P2, O2).
…hout a dialect

NewDB (deprecated since v0.12.0) and QueryParams were the two deprecated
symbols left, against the no-deprecations-before-v1.0 rule: use Open and
Params. Open with a driver name that has no SQL dialect panicked in
GetDialect; it returns an error before the pool is opened, and the message
no longer points at an internal package (AUDIT-001 O4).
… start, stop failed containers

soSeed inserts explicit ids; PostgreSQL SERIAL does not move past them, so the
next generated key collided (Tx_RollbackDiscardsEveryStructOp failed on PG).
A cold MySQL 8.0 start took 63 s against a 60 s limit; a failed start left the
container running when the Ryuk reaper is disabled.
testcontainers.WithWaitStrategy caps the wait at 60 s whatever the strategy's
own startup timeout is, so MySQL starts under load still failed after ~72 s.
…he statement

A construction error in a FromSelect subquery, a Union/UnionAll/Intersect/Except
branch or a With/WithRecursive CTE was dropped with the condition that caused
it, so the statement ran unfiltered (S2-S4). Count, Exists and Explain ignored
even the query's own error (S5, S7). SelectQuery.err walks every embedded
query, at any depth, and reports a query that contains itself instead of
overflowing the stack. nil arguments to these methods panicked in the public
wrappers (S6); they are now build errors, and core no longer drops a nil
set-operation branch silently.
Count with GROUP BY returned the first group's row count and dropped GROUP BY
expressions; it ignored DISTINCT, set operations and CTEs. Exists sent SELECT 1
with only FROM, JOIN and WHERE, so HAVING, DISTINCT, later UNION branches and
CTEs were lost. Both now evaluate the rows All would return: grouped, distinct
and compound queries are counted as a derived table, plain queries keep
SELECT COUNT(*); Exists wraps the query in SELECT EXISTS(...). ORDER BY,
LIMIT and OFFSET stay ignored so a paginated query reports its total.
Reference: GORM Count returns the number of groups.
Every operand of UNION/INTERSECT/EXCEPT was parenthesized; SQLite's
compound-select grammar takes no parentheses, so set operations and
WITH RECURSIVE failed with a syntax error on SQLite although the godoc
claimed support. ozzo-dbx renders the same parentheses (no SQLite override).
On SQLite plain operands are now bare; an operand with its own ORDER BY,
LIMIT, OFFSET, WITH or set operations becomes SELECT * FROM (...), keeping
its rows and grouping. PostgreSQL and MySQL output is unchanged.
…es in hooks, WITH in DetectOperation

P1: UnpinQuery returned true for a cached query that was not pinned.
P3: IsHealthy and Stats().Healthy stayed true after Close.
P4: a second Close with WithHealthCheck panicked (close of closed channel);
Close is idempotent through a closed flag shared by WithContext copies.
P6: statements PostgreSQL/MySQL rejected at prepare time returned before the
QueryHook, so WithLogger never logged them; prepareStatement reports them.
P5: DetectOperation called every WITH statement a SELECT; it now skips the
CTE list and classifies the statement after it, and treats a compound
select starting with a parenthesis as SELECT.
UpdateStruct wrote every column, the key included: the godoc example (a
struct without an ID plus Where on id) ran SET id = 0, and a Where on another
column changed that row's key to 0. Like Model().Update(), it now skips the
primary-key columns (pk tag or the ID/Id fallback); a struct with nothing but
its key is an error. Godoc examples on DB, Tx and QueryBuilder fixed.
The WithRecursive examples put "1 as level" and "h.level + 1" into Select,
which quotes them as identifiers ("1", "h"."level + 1"); the SelectExpr
godoc and SUBQUERY_GUIDE passed the alias as an argument, failing with
"0 placeholders but 1 args". Examples rewritten and checked against the
builder on PostgreSQL, MySQL and SQLite; the rule is on SelectExpr and in
AGENTS.md.
The word-boundary pattern treated "_" as a letter, so password_hash,
access_token, refresh_token, user_password and stripe_api_key were logged
in clear. A sensitive name now matches as a word or as a part of a snake_case
name; over-masking (auth_method) is the safe direction.
…_creators)

MySQL 8 enables the binary log; a user without SUPER then gets error 1419 on
CREATE TRIGGER, so the upsert trigger tests failed on MySQL (first run).
… WithLogger (O3)

QueryHook and WithLogger promise every statement, but the raw methods of DB
and Tx went straight to database/sql. They now report a QueryEvent:
QueryContext when the rows are returned, QueryRowContext with row.Err().
…rrors (O5, O6)

The WarmCache/PinQuery examples warmed hand-written SQL with ? that no builder
query sends (the key is the exact SQL with the dialect's quoting and ) and
that PostgreSQL cannot prepare. The examples now use ToSQL; a test pins that
the ToSQL text is the cache key. The PRODUCTION_DEPLOYMENT health handler
returned err.Error() to clients; QUERY_ANALYZER_GUIDE matched error text.
The public wrappers still said COUNT(*) ignores the select list and Exists
wraps the query; they now state the rule (rows All would return; ORDER BY,
LIMIT, OFFSET ignored). NotExists(nil) in DELETE/UPDATE renders no condition
and is a build error under ADR-020 rule 2 (AUDIT-001 N1, now tested).
…1 phase 4)

docs_guard_test: every relica.X in public Markdown and llms.txt is exported,
db.X(/tx.X( calls name methods of *relica.DB/*relica.Tx (other libraries'
methods in the migration guides allowlisted per file), and internal/ is
not named outside CONTRIBUTING and the v0.4 migration. It found two more
calls that never existed: db.DB().Query in SECURITY.md and db.Exec in
QUERY_ANALYZER_GUIDE. options_reach_test: every internal/core With* option
has a public wrapper or an allowlist entry with its card (OPT-001, BUG-006).
… from RELICA_MYSQL_IMAGE

17 golangci-lint findings in test/: unused nolint directives, unhandled
Close errors, a nested DSN branch (withParseTime), dialect name constants,
spelling, an unused goroutine parameter. testutil starts mysql:8.0 unless
RELICA_MYSQL_IMAGE names another image (for a newest-MySQL CI job).
…L job

- pull_request targets main only; header comments describe GitHub Flow
  (the develop/release/hotfix branches are gone)
- integration runs get -timeout=45m: every test starts a container, and the
  default 10m is shorter than the suite
- go vet and golangci-lint run on the integration test module too (CHORE-001)
- integration-tests-mysql-latest runs the MySQL suite on mysql:latest
  (ADR-019), not a required check; locally the upsert suite passes on
  MySQL 26.10
…(F1)

OrderBy kept the first word and the direction and dropped everything else
without an error: "salary + 1 DESC" ran ORDER BY "salary"; NULLS LAST,
COLLATE and a second column after a comma vanished — a different order on
every database. ozzo-dbx quotes the text before the direction as one name,
which at least fails in the database. Each argument is now a column with an
optional direction, an argument with "(" is used as is, anything else is a
build error pointing to OrderByExpr. Found by Fable review #2a.
kolkov added 27 commits October 26, 2026 23:32
…d (AUDIT-001 B/B2)

test/reachability_integration_test.go runs Rapid Type Analysis
(golang.org/x/tools v0.48.0, test module only) from every exported function
and every method of exported types of package relica — unexported methods
too, since users convert HashExp{...} or SplitAcronyms() to the sealed
interfaces in their own code — and fails on internal functions with no
public path, and on allowlist entries nothing needs. Allowlisted: optimizer
(OPT-001), security validator/audit log and their options (BUG-006).
GOEXPERIMENT is cleared for the load: with jsonv2 on, x/tools' SSA panics
on encoding/json/v2 generics.

Deleted, per the B2 verdicts: core.QuoteIdentifier (dialects quote),
core.WrapError/wrappedError, healthChecker.lastError, logger
Sanitizer.FormatParams/formatValue (dead since v0.17.2), util.IsCanceled,
util.WithTimeout, util.SanitizeString, util.SanitizeIdentifier, and the
tests that covered only them.
…laude Code plugin)

skills/relica/SKILL.md (Agent Skills format): how to find the docs for the
project's Relica version, rules from real bugs, an audit procedure, the
upgrade procedure. scripts/audit.sh audits a project read-only: mistakes
found in projects (H1-H3, M1-M7) and every v0.20.0 behaviour change that
compiles (V1-V10: OrderBy/Select/GroupBy arguments, UPDATE/DELETE without
Where, removed API, upsert actions, row locks, untagged and embedded fields,
models without TableName, Count/Exists), plus style notes. audit_test.sh runs
it offline on fixtures (*.go.txt, outside the Go module): bad/ fires every
check a known number of times, clean/ must produce nothing; the chain style
with the dot at the end of the line is covered. First real run (a project on
v0.19) found five OrderBy calls that silently drop columns today.

.claude-plugin/marketplace.json + plugin.json make the repo a marketplace
(/plugin install relica --marketplace coregx/relica); claude plugin validate
--strict passes. CI job agent-skill runs the tests and the validator.
.gitattributes keeps *.sh LF.
…dule zip

A go.mod under skills/relica/scripts/testdata made each fixture a nested
module: excluded from the relica module zip, and tools generated a go.sum
there (committed by mistake in the previous commit). The test renames
go.mod.txt like *.go.txt when it copies a fixture.
…ines)

The first consumer runs flagged OnConflict calls whose DoUpdate/DoNothing sat
on the next line of the chain or in an if/else that picks one. The check now
reads up to 8 following lines; POSIX awk only (tested with gawk and mawk
1.3.4, the Ubuntu runners' awk). Clean fixture covers both styles.
…ut Relica

The first run on a project that mixes ozzo-dbx (312 files) and Relica (12)
reported ozzo-dbx calls such as OrderBy("`id` ASC") as Relica findings.
Builder checks (V-group, I1-I3, I5, I6) now skip files importing ozzo-dbx
and not Relica; wrapper-typed files that import neither stay in. Clean
fixture covers it; tested with gawk and mawk.
On a large project it listed hundreds of model fields; most keep their name
(Name → name) and only multi-word untagged fields change. It is a pointer for
the upgrade, not a finding.
…n every database

README and WINDOW_FUNCTIONS_GUIDE passed the alias as SelectExpr's argument
("0 placeholders but N args") and used the alias rank, reserved in MySQL 8.
The alias goes inside the expression (rank_no); TestDocs_SelectExprArgsAreParameters
keeps such calls out of the docs, and window_functions_integration_test runs
the guide's forms on PostgreSQL, MySQL and SQLite (Fable review #2, F2).
…ompiler

V4 opened at Model(u).Update("col") or mq.Update("col") and closed at a
later statement's Execute(), so correct Model API code was a HIGH. The chain
now ends at a ";", an unbalanced closing bracket, or a line with balanced
brackets and no trailing dot; multi-line arguments stay inside the chain.
V5 matched echo.Context's QueryParams() in every handler: a grep cannot tell
the receivers apart, and the compiler reports the removed method itself.
Fixtures: clean/internal/handler (echo, Model API), two more V4 cases in bad.
Tested with gawk and mawk (Fable review #2 F4, claude-main skill review #2).
…s 2 when it cannot run

From claude-main's review of the skill:
- CI checks that plugin.json's version is the top CHANGELOG version, or above
  the last release while the top is [Unreleased] (the plugin cache keys on it).
- audit_test.sh also runs testdata/bad as a git work tree (same findings; an
  untracked file is not audited) and removes only a fresh temp directory.
- Files that use GORM, squirrel, bun or ent and not Relica are skipped like
  ozzo-dbx ones.
- A versioned line inside a replace ( ... ) block is printed.
- bash older than 4.3 (macOS 3.2) and a directory it cannot enter exit 2
  instead of an empty audit with exit 0.
…pply to the combined result

a.Union(b).OrderBy("name").Limit(10) sorted and limited only the first
branch and appended all of b, while the docs promised the first 10 of the
union (Fable review #2, F1). Called after the first set operation, OrderBy,
OrderByExpr, OrderBySub, Limit and Offset now apply to the combined result;
called before it they still choose the first branch's rows. This is how the
SQL text reads and how jOOQ, Laravel, goqu and SQLAlchemy define it (v0.19
and ozzo-dbx used the first branch). Count/Exists ignore the combined
result's paging and keep a branch's.

a.Union(b).Intersect(c) was sent flat: SQLite computed (a u b) n c, while
PostgreSQL and MySQL bind INTERSECT first and returned a u (b n c) (F1b,
proven on live PostgreSQL and MySQL: {1,2} instead of {2}). The left side is
now parenthesized there, so a chain means the same on every database.

Two ambiguous forms are errors: an OrderBy in a branch without Limit/Offset
(ignored by PostgreSQL and MySQL), and a set operation after the combined
result's OrderBy/Limit/Offset.

Docs: Union godoc, AGENTS.md Set Operations section, SET_OPERATIONS_GUIDE
(precedence, errors, sorting by an expression via FromSelect; dangling
example paths replaced by the integration tests), MIGRATION_GUIDE, CHANGELOG.
Skill: audit check V11 for chains that call OrderBy/Limit/Offset after a set
operation (gawk and mawk).
Fable review #2 found guides pointing at example files that never existed
and new v0.20.0 API missing from README and llms.txt. Fixed as classes, with
guards:
- TestDocs_LocalLinksResolve: relative links and repository paths in the
  public docs must exist and must not point into the unpublished docs/dev/.
  Dangling example lists now point at the integration tests that run those
  forms; private report links at AGENTS.md sections.
- TestDocs_ExportedNamesAreDocumented: every package-level exported name is
  in AGENTS.md or README. 40 were not; AGENTS.md › Other Public API covers
  them (hooks, logger, MaskArgs, *Col comparisons, NotExists, NotBetween,
  pool options, naming blocks, builder types). README gains UPDATE/DELETE
  need Where (AllRows) and Table and Column Names/ScanMode; llms.txt a
  v0.20.0 rules list. Plugin install lines state Claude Code v2.1.275+.
An embedded struct was flattened whatever its type: sql.NullString became
columns string/valid, a JSON type's Value was ignored, and embedded time.Time
(no exported fields) produced no column at all, so it was silently not
written and not read.

A type is a single value when it is time.Time or the type or its pointer
implements sql.Scanner or driver.Valuer (promoted methods count, as Go sees
the type; GORM and go-jet draw the same line). An embedded single value is
one column named by its db tag, else by Naming from the field name
(NullString -> null_string, Time -> time, sql.Null[string] -> null). A db
tag (name, pk, -) is allowed on it, and an embedded *T of such a type
behaves like a named pointer field (nil = NULL). Embedded plain structs are
still flattened; a tag on one or an embedded pointer to one stays an error;
unexported embedded single values are skipped like unexported fields.
One(&t) with t a time.Time, sql.NullString or another sql.Scanner /
driver.Valuer type walked the type as a struct of columns: nothing matched,
and the value came back zero with err == nil (All the same). The generic
One[T]/All[T] reach the same scanner.

Such a destination is now an error that names the fix: "relica: time.Time
is a single value, not a row: use Row(&v) or Column(&[]v)". A model that
gets Scan/Value only by promotion from an embedded single-value field is
still a row (that field is its column). The same types as a write model
(InsertStruct, Model) are an error too.

One validates its destination before the query runs, so a wrong destination
(including One(&int) and model mapping errors) is reported on an empty
result as well instead of being hidden behind ErrNotFound.
database/sql calls driver.Valuer only on the argument itself
(database/sql/convert.go). A field of type T whose Value method is on *T
alone was passed as T, so Value never ran: a struct type failed with
"unsupported type", and a defined basic type such as type Level int was
silently written as its underlying integer ("3" instead of "level-3",
confirmed on SQLite). A primary key of such a type bound the wrong WHERE
value, so Model().Update() matched no row without an error.

Field values for writes (InsertStruct, BatchInsertStruct, UpdateStruct,
Model Insert/Update/Upsert/UpdateChanged and the Model primary-key WHERE)
now go through argValue: when only *T implements driver.Valuer, the field
is passed as a pointer to a copy, which also works for a struct passed by
value (not addressable) and does not follow changes made after the query
is built.
A struct with only unexported fields, db:"-" everywhere, or nothing but
an unexported single-value type mapped to zero columns. Read, it matched no
column and came back zero with err == nil; written, it failed late with
"Insert requires a non-empty values map" or "primary key not found",
and BatchInsertStruct and UpdateChanged returned no error at all.

The struct walker now returns "relica: <type> has no columns: export
fields or tag them with db:\"...\"" for every path: InsertStruct,
BatchInsertStruct, UpdateStruct, the Model API, One (before the query) and
All.

Model Update, UpdateChanged, Delete, Find and Upsert replaced any mapping
error with a bare "model: primary key not found"; they now wrap the cause
("model: primary key not found: <cause>"), so it names the fix.
Column is documented (and worked in ozzo-dbx) as scanning the first column
of every row. Relica passed one destination to rows.Scan, so any query with
more than one result column failed with "expected N destination arguments
in Scan, not 1".

The first column now goes into the slice and the other columns are read
into one shared any sink and dropped; any takes every driver value as is,
so their types do not matter. The first column is converted exactly as
before. The scan loop moved to scanFirstColumn so Column reports every
failure through one hook call.
isValueType counted methods promoted from an embedded field, so a plain
struct such as Audit{sql.NullString; X int} became one column written
through NullString.Value: X was never written or read. An unexported
embedded struct whose Scan/Value came by promotion was skipped entirely.

A type is now a single value only when its Scan or Value is its own.
reflect cannot tell a declared method from a promoted one, so a method that
an embedded field also has counts as promoted and the type is flattened; a
wrong guess fails on a missing column instead of dropping fields. A method
no embedded field has must be declared, so Date{time.Time} with its own
Scan and Value stays one column (time.Time has neither). isValueRow is gone;
models, One/All destinations and embedded fields use the one predicate.

Base{sql.NullString} embedded in a model is now column null_string (was
base); Audit{sql.NullString; X int} is null_string and x.
SelectQuery.Model bound each primary-key value with reflect's Interface(),
so a key type whose Value method is on *T alone was passed by value and
database/sql never called Value: db.Select().Model(&m) looked up the raw
value (5 instead of level-5) and returned ErrNotFound for an existing row.
It now goes through argValue, like every other write and Model API path.
…er, Column

Documents commits 6a1c19f..e680c49 (Fable review #2 F3 and follow-ups):
CHANGELOG (ADR-018 section), AGENTS.md Table and Column Names (embedded
types, single values are not rows), MIGRATION_GUIDE rows, README naming
paragraph, and the audit V8 message.
… are errors

Fable review #3 F1/F2. Under ScanRequireFields (default):
- Select("id", "email") into a struct whose tag was renamed to mail read
  email and dropped it, err == nil: a column the code named must land in a
  field. Select() / Select("t.*") of a wider table list nothing and keep
  ignoring extra columns.
- Two result columns for one field (Select("a.id", "b.id"), SELECT * over a
  join where both tables have id) let the later column overwrite the earlier
  one silently; now an error that says to alias one.
ScanLenient keeps the ozzo-dbx behaviour. Integration tests on PostgreSQL,
MySQL and SQLite (test/scan_columns_integration_test.go), failing before.
Fable review #3 F4. reflect can neither read nor write an unexported field,
so it is no column — fine when untagged, but a db tag (a db:",pk" key
included) or an embedded single value (its Scan/Value) asked for a column
and was dropped without a word: an embedded unexported key type vanished
and Update failed far from the cause. Both are errors now; untagged
unexported fields stay ignored. Docs: AGENTS.md also says that a type that
declares its own Scan/Value and embeds a Scanner/Valuer is flattened.
…godoc

Fable review #3 F3, F6, F7, F8:
- AGENTS.md, MIGRATION_GUIDE and the addSetOperation error recommended
  c.Union(sorted), which the code rejects (a branch ORDER BY without LIMIT
  orders nothing): a sorted and limited compound can be a branch, a sorted
  one only a FromSelect source.
- ExplainAnalyze is PostgreSQL and MySQL only (SQLite returns an error).
- internal/core Count godoc still described the pre-v0.20.0 set-operation
  paging.
- WINDOW_FUNCTIONS_GUIDE generated SQL shows what Relica renders (quoted
  ORDER BY alias, AS as written).
Fable review #3 F5: the chain-opening match ran on the whole line, so
// Danger: db.Delete("t").Execute() ... in a repo's own notes was a HIGH.
nocomment() (strings respected) strips the comment before the match; clean
fixture gains both comment forms. The Not greppable list now names the
v0.20.0 scanning errors and the chains split over statements that V4/V11
cannot follow. gawk and mawk PASS.
…xpression

Fable review #3 F9. In("id", sub), Eq, Between, HashExp, Coalesce/Case
arguments and Set values bound a *SelectQuery as a query argument: ToSQL
showed a plain placeholder and the driver failed late with "unsupported
type relica.SelectQuery". coreArg turns it into a value whose Err() makes
the condition or SET value a build error (core.ExpressionError), so nothing
is sent; the error says to use q.AsExpression(). HashExp and Set copy the
caller's map only when a value needs converting. Insert/Upsert values are
bound as plain arguments by design, where database/sql already rejects it.
Suggested by gofro after rebuilding on bd7cafe: Select(..., "COALESCE(u.full_name, '')")
into a field tagged full_name — PostgreSQL names the column coalesce, so the
field stayed empty on v0.19 and is an error on v0.20.0 (ScanRequireFields).
The check reads only the Select/AndSelect call's own string arguments
(a Where("LOWER(x) = ?") on the same line is not one). Fixtures: two bad, two
aliased forms clean. gawk and mawk PASS. AGENTS.md states the alias rule.
gofro: V12 at 2edb7e4 gave 5 false positives on gofroprom — scalar
subqueries named by the outer SelectSub alias, and Row()/Column() reads,
which scan by position. V12 now follows the chain over lines (scan()) and
reports a function column without AS only when the same chain calls All or
One. Multi-line Select arguments are read too: the one true positive was a
multi-line Select(...) ending in All, which the single-line version missed.
On gofroprom main: 1 finding (that one), was 5 false + 0 true. Fixtures:
multi-line bad case; Row, Column and SelectSub scalar subquery clean.
gawk and mawk PASS.
CHANGELOG [Unreleased] -> [0.20.0] - 2026-10-27; ROADMAP current state.
CHANGELOG: the Count/Exists line on set operations described the old
first-branch rule, and the set-operations entry broke off mid-sentence.
@kolkov
kolkov merged commit 3d44568 into main Oct 27, 2026
12 checks passed
@kolkov
kolkov deleted the fix/v0.20.0 branch October 27, 2026 05:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant