AGENTS.md, docs/contract.md and the new-business-module skill showed
the older actions and made returning a copy from Generate() a rule to
remember. They now show the generic actions, whose type parameters are
the rule, and say the Generate() rule holds only for modules still on
the deprecated actions.
IndexAction, ViewAction, CreateAction, UpdateAction and DeleteAction
stay and behave as before; each now points at its generic replacement.
Nothing in this repository calls them any more.
The five /sysjob routes use the generic actions. The detail route uses
ViewAs to keep answering SysJobItem, whose entryId the edit form reads
where the model marshals entry_id. GenerateM becomes ToModel, and the
Generate methods go.
Recorded before the change and replayed after it, the same 14 requests
get byte-identical responses; answering the detail with the model
instead of SysJobItem makes exactly the two detail requests differ.
With a real ToModel on both modules, a test now builds a package that
pairs the demo model with the jobs request and requires the build to
fail on that pairing, next to the same package with the pair corrected.
The five routes use actions.Index, View, Create, Update and Delete. The
control request's GenerateM becomes ToModel, returning the model type
itself, and the Generate methods on the model and the three request
types go, along with the tests that held them to returning copies:
nothing calls them any more.
Recorded before the change and replayed after it, the same 18 requests
- creates, lists with filters and paging, details, updates and deletes,
found and missing - get byte-identical responses.
Index, View, ViewAs, Create, Update and Delete take the model and the
request as type parameters. They serve the same routes as IndexAction
and its siblings and answer them the same way; what they drop is what
the caller had to get right.
The older actions serve every request from instances passed at
registration, so each model and request type needs a Generate that
returns a copy, and a list route takes a func() interface{} whose
element type nothing checks. Here every request declares its own
values, and a model paired with the wrong request does not compile.
ViewAs answers a detail route with a type other than the model, as
ViewAction's f did.
Beyond that they differ in three places, all where the older ones were
wrong: a request with no database is answered with 500 instead of an
empty 200; the key is matched against the primary-key column as a
value, not handed to Where, which reads a string as SQL; and there is
no GenerateM whose error could be dropped.
A test runs the old and new actions on the same types through one
request script and requires every response to match.
WithContextDb and the five CRUD actions passed the *gin.Context itself
to GORM as the query's context. database/sql watches the context of a
query that returns rows inside a transaction from a goroutine of its
own, which can still read it after the handler returns - and gin hands
that Context to the next request as soon as the handler returns. GORM
wraps every write in a transaction, and an INSERT on SQLite or
PostgreSQL returns the new key as a row, so any write raced with the
next request's reset. The race detector reports it on the second of
two sequential requests.
They now pass c.Request.Context(), which belongs to one request only
and is cancelled when its client goes away.
Every generator route was reachable by any account that could log in:
the /gen and /db routes were in CasbinExclude, and the /sys/tables
routes were mounted without AuthCheckRole. Any user could read and
change any table's configuration and, in dev mode, generate files
from it.
All thirteen now go through AuthCheckRole. admin is let through as
before; another role needs the generator's menus, whose APIs the
previous commit binds and grants. The captcha, registered alongside
them, stays public.
A role is granted an API through its menus: saving a role writes a
casbin_rule for every API bound to each menu it holds. The seed data
bound none to 代码生成 or 代码生成修改, so a role given either menu
received no API, which went unnoticed only because every generator
route skipped the role check.
Migration 1786700011000 binds the nine APIs the generator page calls
and the four its edit page calls, creating any that sys_api lacks, and
grants them to each role that already holds the menu. Menus are found
by component, so renumbered ones are found and deleted ones skipped.
Tested on SQLite, MySQL and PostgreSQL, with SQL Server in CI.
The package, table and business names a table's configuration carries
become directories and file names, and nothing on the server checked
them; the config page's patterns were the only check.
Each is now checked against what it becomes: a package name is
lowercase letters and digits, a table name letters, digits and
underscores, a business name an identifier starting with a lowercase
letter. None allows a dot or a separator, and none is stricter than
the config page. The check runs on import, where a list holding one
bad name is refused whole, on save, and again before generating, so a
configuration saved before this check existed is refused rather than
trusted.
The generator joins a table's package, table and business names into
the paths it writes, and wrote them with os.MkdirAll and os.WriteFile.
A name carrying "..", or a symlink under the target directory pointing
elsewhere, took the write wherever it led.
Every file is now written through os.Root: backend files under the
working directory, frontend files under gen.frontpath. A path that
resolves outside its root is refused, symlinks included, and the
request reports the write failure without writing anything outside.
A table whose columns are all its key and ones the framework fills in
got an add button and an edit link opening an empty form. The page now
leaves out both, the dialog, useForm and the API calls only the form
used. A page with anything to enter is generated byte for byte as
before.
The config page hid both columns along with the ones the framework
fills in, so a generated page could not list or filter by when a row
was created or last changed without editing sys_columns by hand. They
are now shown; id, create_by, update_by and deleted_at stay hidden.
The templates already keep both out of the create/edit form.
The test imports a table, ticks list and query on created_at through
the config page's own endpoints, generates it, and filters the
generated list with the value the generated date picker sends.
The config page saves an unticked query box as "0", and the DTO and API
templates tested IsQuery with a bare if, which is true for any
non-empty string. A column ticked and then unticked became a filter of
the generated list request and a documented query parameter anyway.
They now require "1", as the TypeScript and Vue templates already do.
The model template imported "time" for any queryable time.Time column,
though whether a column is queryable only shapes the DTO. A queryable
created_at, which the model takes from models.ModelTime rather than
declaring, left the import unused and the generated package failed to
compile.
dropFixture soft-deleted the sys_tables rows it cleaned up. The config
page's business-name check counts soft-deleted rows, so a second test
importing the same table could not save its configuration. The delete
endpoint removes these rows with Unscoped; the fixture now does too.
Both commands discarded genFile's error, and genFile discarded the
errors from rendering its templates and from pkg.FileCreate. A file
that could not be written ended the process from inside FileCreate;
with core v2.11.0 it would have been reported as success instead.
genFile now returns every error, and both commands print it and exit
non-zero. In `app`, a missing router template no longer panics on a
nil template, and neither file is written unless both render.
v2.11.0 makes pkg.FileCreate return its error instead of ending the
process when the file cannot be created. Two comments in the code
generator described the old behaviour in the present tense.
The setval below now targets sys_job_job_id_seq, the sequence job_id
actually uses, but the create above it still named sys_job_id_seq, so a
fresh PostgreSQL install was left with an unused sequence of that name.
Preview, GenCode, GenApiToFile and GenMenuAndApi read a table's
configuration with tab, _ := table.Get(...), so an id that matched nothing,
or a failed query, carried on with an empty table. GenCode and Preview then
refused it as having no primary key, and GenMenuAndApi went on to seed menus
and APIs for it.
Each now answers with the lookup's error.
Preview, NOActionsGen and genApiToFile assigned each template's Execute
error to err and never read it - 19 times - so a template failing part-way
left a truncated file behind it and the request reported success. The
files were written through pkg.FileCreate, which returns no error at all:
when it cannot create a file it closes a nil *os.File and calls
log.Fatalln, ending the server process. Four of the directories it writes
into were created with _ = pkg.PathCreate(...).
Every template now renders into memory first; if any fails, nothing is
written and the response names the template. Files are then written
through writeGenerated, which creates the directory and returns any error
instead of exiting. NOActionsGen and genApiToFile report whether they
succeeded, so GenCode and GenApiToFile no longer follow an error response
with "Code generated successfully".
SysTables.Create and Update wrote each column with _, _ = …, so a column
the database rejected was dropped while the request reported success. And
because neither ran in a transaction, returning that error alone would not
have been enough: the table row and every column before the failing one
were already committed, leaving a table the config page lists with columns
missing and code generated from fewer columns than the table has.
Both now run the table and its columns in one transaction and return the
first column's error, named. Create rolls back to nothing; Update leaves the
table and every column as they were. Both callers already answer an error
with a 500.
The generated form never showed the key column, which is right for a key
the database assigns and left a string key with nowhere to be typed: a
table keyed on one could be created through the API but not from its page.
For a string key the form now carries an input for it, required, and
disabled once the record exists - bound to form.isEdit, the same way the
hand-written dictionary and config pages lock their natural keys. A page for
a table keyed on an int renders exactly as before.
This needs go-admin-ui's useForm to record whether a form was opened to
create or to edit (go-admin-ui#310). The useForm before it inferred the mode
from the key's presence, so typing the key into a create form turned the
submit into an update.
The generator had no test that compiled what it writes; test/gen_test.go is
commented out. This one goes through the real path end to end: fixture
tables in MySQL, the import handler, GenCode writing its files, the files
compiled into this module's own packages through a -overlay, and a CRUD
test run over HTTP against the generated handlers.
The shapes are an int key named id, an int key named otherwise, a string
key, no key and a composite key. The last two must be refused with nothing
written. For the rest: read and update through the path parameter, a
missing key neither found nor created by an update, a key of "1=1" read as
a value, and delete by key.
The importer only reads MySQL, so the test needs GO_ADMIN_TEST_MYSQL_DSN;
without it the test skips locally and fails under CI. It also asserts each
generated CRUD test actually ran, since a -run pattern that matches nothing
exits 0.
The templates address a row by a single key: one field on the model, one
path parameter, one value per row in a delete. A table with no primary key
rendered a model whose GetId returned an empty field name, and a composite
key was silently narrowed to whichever of its columns the importer read
last. Both wrote files that break the build of the whole module.
Preview and NOActionsGen now check first and return an error naming the
table and, for a composite key, its columns. Nothing is written.
Update looked its row up and ignored the result. When nothing matched -
because the key does not exist, or because the caller's data scope filters
the row out - the model stayed at its zero value, Generate copied the
request onto it, and Save, finding a zero key, inserted a new row. The
update of a row the caller may not see became a row they now own, and the
RowsAffected check meant to refuse it never fired, because the insert did
affect one.
A missing row now ends the update with the same "无权更新该数据" the
RowsAffected check returns, and any other lookup error is returned as is.
This applies to every generated table, an int id included.
Get and Update found their row with First(&model, id). GORM reads a string
passed that way as a SQL condition rather than a key, so with a string
primary key the path parameter went into the WHERE clause as written: "c-1"
failed as the expression c - 1, and GET /…/1=1 returned a row.
Both now filter on clause.PrimaryColumn with the id as a bound value, which
GORM resolves to the model's own quoted key column whatever it is called.
Delete keeps Delete(&model, ids): a slice is always taken as key values.
The model and DTO templates assumed every table's key is an int column named
id. For any other key the generated backend did not compile - the model
embedded models.Model, which supplies Id, skipped the real key column, and
GetId then returned a field that did not exist ("e.Code undefined"). Past the
compile error there was more of the same assumption:
- GetReq and UpdateReq bound the key with uri:"<json field>", while every
generated router registers it as :id, so any other name bound nothing;
- InsertReq tagged the key json:"-", so a key the database does not assign,
such as a string, could never be supplied;
- DeleteReq took Ids []int, so a string key could not be deleted.
models.Model now stands in for the key only when the key is exactly an int
named Id; anything else is declared as its own primaryKey field. The key is
bound from :id, accepted from the request body when it is not an int, and
deleted by a list of its own type. A table keyed on an int id renders
byte-for-byte as before.
Picks up two releases at once:
- v2.9.0: app.Register normalizes Manifest.Requires the way it already
normalized Code, so the dependency list stored in sys_app.requires is
spelled the same way as the app codes beside it; and a version
component beginning with '+' is reported as a signed component rather
than as a build-metadata suffix.
- v2.10.0: builds with Go 1.27, which this repository already declares,
and fixes a %S verb in the listener's shutdown log.
All three modules move together: example/app-order requires core directly
and test/e2e-apporder does indirectly. No other requirement changes.
log.Error is the unformatted entry point, so "find dept list error, %s"
went out with the %s intact and the error text glued to it:
find dept list error, %sconnection refused
Errorf is what the line meant. go vet reports this once go-admin-core's
logger is recognised as a print-style wrapper, which it is from v2.10.0.
govulncheck reports seven standard-library vulnerabilities against go1.26.5
that this code actually reaches, and none against go1.27.1.
It also clears the way for the next go-admin-core release, which declares
go 1.27.1: a module cannot require a dependency whose language version is
newer than its own. Doing it in its own commit keeps that upgrade to a
one-line dependency bump.
All three modules in the tree move together. test/e2e-apporder requires the
main module, so leaving it behind fails the moment the main module declares a
newer version - "updates to go.mod needed", before a test runs.
example/app-order only requires go-admin-core and would still have resolved,
but it was declaring 1.25.13, two releases back and out of support.
The workflows pin the Go version explicitly instead of reading go.mod, and
the four READMEs state it under environmental requirements, so those move
with it too.
No dependency changes - go mod tidy leaves every go.sum untouched.
The note told the reader the scheduler stood in the way of raising the
replica count. It no longer does: one enabled job fires once however many
pods there are, a pod that loses the lease stops scheduling, and one that
exits hands the lease back rather than making its successor wait it out.
The shared log volume still does stand in the way, and that is now the only
thing the note asks for before the number goes up.
A second instance pointed at the same database did not divide the work, it
overwrote it. Every instance registered the whole enabled list into its own
cron.Cron, and startup ran `UPDATE sys_job SET entry_id = 0 WHERE entry_id
> 0` across the entire table, so the newest process erased the ids the
previous one wrote and put its own over the top. Neither symptom logged
anything: an enabled job fired once per instance, and stopping one from the
UI removed an entry from the wrong process and answered 200 either way.
A supervisor per tenant now keeps the scheduler in step with the lease.
Holding it is not decided once at startup, because both of the other
answers are wrong for longer than a moment:
- an instance that never got the lease keeps asking, so the death of the
holder does not stop the jobs until somebody restarts a process by hand;
- an instance that holds it stops scheduling as soon as its lease has
lapsed, because a holder still scheduling after the lease has gone
elsewhere is the two-schedulers defect reached from the other side.
A failed renewal is not a lost lease. The scheduler keeps running until the
lease could actually have expired: a database that is briefly unreachable
must not stop the jobs, and cannot have handed them to anyone else, because
nobody else can reach it to take the lease either.
Stopping is no longer arranged by startCrontab. A scheduler now stops for
two different reasons and only the supervisor knows which - and since a
start happens every time the lease is taken, registering a shutdown
callback there would add one per leadership change for the life of the
process, because SetShutdown appends.
sys_job.entry_id keeps its meaning. This does not distribute the jobs and is
not meant to: the HTTP side scales, the scheduler stays single-writer.
Fixes#915.
One row per database, taken and renewed by single UPDATE statements whose
RowsAffected the database decides. Nothing uses it yet; the scheduler is
wired to it next.
The two timestamps are epoch milliseconds in a BIGINT rather than timestamp
columns, which is the one decision here worth explaining. A timestamp does
not survive the trip through a driver unchanged: read over go-admin's own
`parseTime=True&loc=Local` DSN, MySQL's UTC_TIMESTAMP arrives relabelled as
local time, and on a UTC+8 host every lease is eight hours out. It is
invisible to any test that compares the lease against itself, because each
instance's own arithmetic stays self-consistent - only the comparison
between two instances is wrong, which is the only comparison that matters.
An integer has no timezone for a driver to apply.
The migration seeds the row free. There is no insert path at runtime, so two
instances starting together cannot race to create the row they are both
trying to claim, and neither has to tell a duplicate-key error apart from a
real one in whichever driver it is running against. A missing row is
therefore reported rather than recovered from: silently never scheduling
anywhere is the worse failure.
The current-time expression differs per dialect and all four are covered:
MySQL, PostgreSQL and SQL Server against real servers, SQLite by default.
MySQL is the dialect most installations run and the only registered driver
with no service here. The scheduler lease that follows reads the database's
clock, and the first implementation read it as a timestamp: over go-admin's
own `parseTime=True&loc=Local` DSN, MySQL's UTC_TIMESTAMP comes back
relabelled as local time, so on any host that is not UTC every lease was one
zone offset out. Every assertion that compared the lease only against itself
still passed, and the three dialects already here could not see it.
The DSN keeps loc=Local on purpose: it is what config/settings.yml ships. A
DSN here that quietly differed would test a configuration nobody runs.