feat(ogar-render-askama): T5 — SurrealqlTable (completes the +5 kit)#86
Conversation
…assView) Fourth real emitter per Northstar plan §3. Mirror of Redmine's _form.html.erb shape: one shared form template per resource, here substrate-agnostic via the codebook + ClassView. Same plumbing as T2/T3 with a new dimension (InputKind for form-input controls, parallel to ColumnKind for cell formatters). Surface (additive): - ArtifactKind::HtmlForm variant (slot 3, append-only). ALL.len() now 7. - form_view::InputKind — 9-variant enum: Text, TextArea, Number, Range, Checkbox, Date, DateTime, Select, Hidden. Append-only; template stems stable. - form_view::default_input_kind_for(name, type_name) — resolver mirroring list_view::default_kind_for. id/*_id → Hidden, *_ratio → Range, text+prose → TextArea, boolean → Checkbox, date/datetime → date inputs, numeric Rails types → Number, else → Text. - artifact_kinds::inputs — one binding-struct + askama template per InputKind (9 total). Each escape="html" so per-input variables are HTML-escaped; spine concats with |safe. - artifact_kinds::inputs::InputData — owned data variant the caller supplies, keyed by InputKind. Carries value, required, options, etc. - artifact_kinds::html_form::FormFieldSource + FormSource — the public call-site shapes for one field and the whole form. - artifact_kinds::html_form::render_form(class_id, concept, &FormSource) — the substantive entry point. - HtmlFormEmitter — codebook-only proof-of-shape for the for_kind dispatch path; synthesises a new-record form from class attributes. - empty_input_for(InputKind) — convenience for callers that want a blank form. - templates/dispatch/html_form.askama — spine; <form> + <fieldset> + per-field <div class="form-field-…"> wrappers (hidden fields emitted bare without wrapper). - 9 templates/dispatch/input/*.askama: text, textarea, number, range, checkbox, date, datetime, select, hidden. Refactor — cell-dispatch factored: - New artifact_kinds::cells::render_cell_body(&CellData) → String — shared per-kind dispatch. T2's render_cell and T3's render_cell_body now call it. T4 follows the same pattern with InputKind via inputs::render_input_body. "Three points form a line" (Northstar §1.6 notice from #84). Tests (+13, 38/38 total): - form_view::tests (8): template stems stable, html_input_type None-vs-Some, every default_input_kind_for arm (Hidden / Range / TextArea / Number / Checkbox / Date / DateTime / Text fallback). - html_form_proof_of_shape_renders_inputs_for_class_attributes — proof of shape on project_role; data-class-id="0x0117" + per-attribute form-field-<name> wrappers + POST/Create defaults. - html_form_dispatches_input_kinds_to_their_sub_templates — explicit field per kind; pins every <input type=…>, textarea, select option selected="…", checkbox hidden-zero idiom, date/datetime types, hidden-without-wrapper, edit-form record_id, CSRF token, legend, submit/cancel buttons. - html_form_escapes_data_derived_strings_xss_regression — same XSS contract as T2/T3 spines: label / hint / css_classes / action / legend / submit_label / values / placeholder / csrf_token all escape; action="/<bad-action>" → action="/<bad-action>". - input_kind_resolver_is_wired_through_render_kit — pin the public re-export. - artifact_kind_all_const_enumerates_every_variant — ALL.len() 6 → 7. Workspace check + workspace test green. 4/7 real emitters now (T1 RustStruct, T2 HtmlListView, T3 HtmlDetailView, T4 HtmlForm); 3 stubbed for T5 (SurrealqlTable) and the two roadmap-only / deprecated variants.
…er of the +5 kit) Fifth real emitter, completing the Northstar plan §3 +5 kit. Codegen flavour: emits SurrealQL DDL the SurrealDB engine consumes at boot. Direct sibling of T1 (RustStruct) — both consume `&Class` at build time and emit typed source for downstream compilers/engines. Stacked on top of T4 (PR #85) since T4 isn't merged yet; rebase drops the duplicated T4 commit cleanly when #85 merges first. Surface: - artifact_kinds::surrealql_table::SurrealqlTableEmitter — the concrete emitter; pure read of &Class, no I/O. - templates/dispatch/surrealql_table.askama — spine: DEFINE TABLE + DEFINE FIELD per attribute + DEFINE FIELD per family edge (with the comment naming the kind + target) + DEFINE INDEX per primary-label attribute. - Dispatcher: SurrealqlTable now routes to the real emitter; stub list in `stub_emits_marker_for_unimplemented_kinds` shrinks to just OpenapiSchema + NodeGuidRoutingArm. Mapping (Rails → SurrealQL): - string/text → string - integer/big_integer/bigint → int - float/double → float - decimal/monetary → decimal - boolean/bool → bool - date/datetime/timestamp → datetime - json/jsonb → object - belongs_to / has_one / future-variants → option<record<target>> - has_many / habtm → array<record<target>> Reserved-word escape: SurrealQL reserves `type` / `value` / `id` / `for` / `select` / `where` / etc. Field names that collide get back-tick- quoted (project_actor's `type` attribute → `DEFINE FIELD \`type\``). TS-side companion of Rust's `r#type` (codex P1 on #78) and TS's `"type":` (T2 from #83). Default index: classes with a `name` / `subject` / `title` / `label` attribute get a non-unique index on it (`idx_<name>`). Other classes (billable_work_entry, etc.) emit no default index — consumers add per their own policy. Tests (+6 for T5, 44/44 total): - surrealql_table_emits_define_table_with_codebook_meta — proof of shape on project_work_item: TABLE name + CODEBOOK comment with 0x0102 + DEFINE FIELD per attribute. - surrealql_table_maps_rails_types_to_surql — project_role's name/string, position/int, permissions/string mapping pinned. - surrealql_table_emits_record_links_for_family_edges — all 12 billable_work_entry edges emit DEFINE FIELD; at least one `option<record<...>>` link with snake_case'd target (`tax_policy`). - surrealql_table_emits_primary_label_index — project_role gets `DEFINE INDEX idx_name ON project_role FIELDS name`. - surrealql_table_escapes_reserved_identifiers — project_actor's `type` attribute → `DEFINE FIELD \`type\`` (never bare). - surrealql_table_default_indexes_only_when_class_has_primary_label — billable_work_entry (no name/subject/title/label) does NOT get a default index. - stub_emits_marker_for_unimplemented_kinds — SurrealqlTable removed from the stub list. Workspace check + workspace test green. +5 kit complete: T1 RustStruct (codegen), T2 HtmlListView (render), T3 HtmlDetailView (render), T4 HtmlForm (render), T5 SurrealqlTable (codegen). 5/7 ArtifactKind variants are real; only OpenapiSchema (deprecated, anti-pattern #8) and NodeGuidRoutingArm (roadmap-only) remain stubbed. Note on op-surreal-ast: this crate emits DDL for the canonical layer's own consumption (the byte-identical pin in op-surreal-ast still holds for that crate's own emission; the two paths are sibling — see Northstar plan §2.1 / §2.4 for the call-site picture).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 61b2c142c0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| {# driven by `fields` (per-attribute pre-rendered <input>s). #} | ||
| {# Pre-rendered field bodies are |safe (each sub-template escapes its #} | ||
| {# own variables); everything else is HTML-escaped by the spine. #} | ||
| <form class="ogar-form" data-class-id="{{ class_id_hex }}" data-concept="{{ canonical_concept }}" method="{{ method }}" action="{{ action }}"> |
There was a problem hiding this comment.
Submit edit forms with a valid HTML method
When callers use the documented edit path (FormSource.method is described and tested with "patch"), this value is written directly into the HTML method attribute. Browsers only submit forms with GET/POST, so PATCH needs a POST form plus a hidden method override; otherwise these generated edit forms submit with the wrong method and miss PATCH routes.
Useful? React with 👍 / 👎.
| match a.kind { | ||
| AssociationKind::HasMany | AssociationKind::HasAndBelongsToMany => { | ||
| format!("array<record<{target_table}>>") | ||
| } | ||
| // BelongsTo + HasOne + any future variant → optional record link. | ||
| _ => format!("option<record<{target_table}>>"), |
There was a problem hiding this comment.
Preserve association ownership in Surreal fields
For classes with HasMany/HasOne associations such as project_work_item.journals, ogar_vocab::AssociationKind marks those as non-owning, but this branch creates fields on the current table, and the wildcard also makes every BelongsTo optional even when optional is unset or false. That corrupts the generated schema by storing inverse collections and allowing required FK records to be absent; emit fields only for owning BelongsTo associations and wrap option<...> based on Association::optional.
Useful? React with 👍 / 👎.
| if !concept.is_empty() { | ||
| concept.to_string() | ||
| } else { | ||
| class.name.to_ascii_lowercase() |
There was a problem hiding this comment.
Quote non-bare SurrealQL table identifiers
When rendering an unpromoted Class whose source name contains dots or namespaces, such as Odoo sale.order, this fallback lowercases and emits the raw string into DEFINE TABLE and ON. That produces invalid SurrealQL like DEFINE TABLE sale.order; run the fallback through a SurrealQL identifier quoting helper, including backtick escaping, instead of writing it bare.
Useful? React with 👍 / 👎.
What
T5 (SurrealqlTable) — the codegen-flavour finisher of the Northstar +5 kit. Stacks on top of #85 (T4); the duplicated T4 commit drops on rebase when #85 merges first.
After this lands, 5/7 ArtifactKind variants are real. Only the deprecated
OpenapiSchema(anti-pattern #8) and the roadmap-onlyNodeGuidRoutingArmremain stubbed.Direct sibling of T1 (RustStruct) — both are codegen-flavour, both consume
&Classat build time, both emit typed source for downstream compilers/engines.What it emits
Sample render of
project_role():Mapping
TYPEstring/textstringinteger/big_integer/bigintintfloat/doublefloatdecimal/monetarydecimalboolean/boolbooldate/datetime/timestampdatetimejson/jsonbobjectTYPEbelongs_to/has_oneoption<record<target>>has_many/has_and_belongs_to_manyarray<record<target>>Target table is the canonical concept name when it's promoted (
tax_policy,project_membership, …) or snake_case'dclass_nameotherwise.Reserved-word escape (SurQL-side companion of Rust + TS escapes)
SurrealQL reserves
type/value/id/for/select/where/ etc. The emitter back-tick-quotes field names that collide:project_actordeclares an attribute literally namedtype(Rails STI convention) — the same hazard that bit Rust on codex P1 #78 and TypeScript on T2. Each emitter handles it locally per the per-target convention.Default index
Classes with a
name/subject/title/labelprimary attribute get a non-unique index on it (idx_<name>). Classes without one (billable_work_entry, etc.) emit no default — consumers add per their own policy.Tests (+6 T5, 44/44 total)
surrealql_table_emits_define_table_with_codebook_meta— proof of shape onproject_work_item: TABLE name + CODEBOOK comment0x0102+DEFINE FIELDper attribute.surrealql_table_maps_rails_types_to_surql—project_role's name/string, position/int, permissions/string mapping.surrealql_table_emits_record_links_for_family_edges— all 12billable_work_entryedges emitDEFINE FIELD; at least oneoption<record<…>>link with snake_case'd target (tax_policy).surrealql_table_emits_primary_label_index—project_rolegetsDEFINE INDEX idx_name ON project_role FIELDS name.surrealql_table_escapes_reserved_identifiers—project_actor.type→DEFINE FIELD `type` ON project_actor.surrealql_table_default_indexes_only_when_class_has_primary_label— negative pin:billable_work_entrygets no default index.Workspace check + workspace test green.
Note on op-surreal-ast
This crate emits DDL for the canonical layer's own consumption (
render(class, ArtifactKind::SurrealqlTable)). The byte-identical-output pin inopenproject-nexgen-rs::op-surreal-aststill holds for that crate's own emission — the two paths are sibling, not competing. See Northstar plan §2.1 / §2.4 for the call-site picture; C3 (theop-surreal-ast::from_class_viewadapter) is the bridge.+5 kit complete
C-series consumer wirings (C1–C5) are the next move; each PR is small and lands across the openproject-nexgen-rs / redmine-rs repos.