PageSourceSearch

https://orm.st/assets/js/97979581.0372bfff.js

js orm.st collected 2026-10-07 16:55:07 UTC 20,330 bytes, 1 lines download raw bytes

1"use strict";(globalThis.webpackChunkstorm_docs=globalThis.webpackChunkstorm_docs||[]).push([[7072],{21920(e,n,t){t.r(n),t.d(n,{assets:()=>d,contentTitle:()=>c,default:()=>m,frontMatter:()=>o,metadata:()=>a,toc:()=>u});const a=JSON.parse('{"id":"first-entity","title":"First Entity","description":"This guide walks you through defining your first Storm entity, creating an ORM template, and performing basic CRUD operations. By the end, you will have inserted a record into the database and read it back.","source":"@site/versioned_docs/version-1.11.5/first-entity.md","sourceDirName":".","slug":"/first-entity","permalink":"/docs/1.11.5/first-entity","draft":false,"unlisted":false,"editUrl":"https://github.com/storm-orm/storm-framework/edit/main/docs/first-entity.md","tags":[],"version":"1.11.5","frontMatter":{}}');var r=t(74848),i=t(28453),s=t(4865),l=t(19365);const o={},c="First Entity",d={},u=[{value:"Define an Entity",id:"define-an-entity",level:2},{value:"Create the ORM Template",id:"create-the-orm-template",level:2},{value:"Insert a Record",id:"insert-a-record",level:2},{value:"Read a Record",id:"read-a-record",level:2},{value:"Update a Record",id:"update-a-record",level:2},{value:"Remove a Record",id:"remove-a-record",level:2},{value:"Transactions",id:"transactions",level:2},{value:"Summary",id:"summary",level:2},{value:"Next Steps",id:"next-steps",level:2}];function h(e){const n={a:"a",code:"code",h1:"h1",h2:"h2",header:"header",li:"li",ol:"ol",p:"p",pre:"pre",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,i.R)(),...e.components};return(0,r.jsxs)(r.Fragment,{children:[(0,r.jsx)(n.header,{children:(0,r.jsx)(n.h1,{id:"first-entity",children:"First Entity"})}),"\n","\n",(0,r.jsx)(n.p,{children:"This guide walks you through defining your first Storm entity, creating an ORM template, and performing basic CRUD operations. By the end, you will have inserted a record into the database and read it back."}),"\n",(0,r.jsx)(n.h2,{id:"define-an-entity",children:"Define an Entity"}),"\n",(0,r.jsxs)(n.p,{children:["Storm entities are plain data classes (Kotlin) or records (Java) that implement the ",(0,r.jsx)(n.code,{children:"Entity<ID>"})," interface. Annotate the primary key with ",(0,r.jsx)(n.code,{children:"@PK"})," and foreign keys with ",(0,r.jsx)(n.code,{children:"@FK"}),". Storm maps field names to column names automatically using camelCase-to-snake_case conversion, so no XML or additional configuration is needed."]}),"\n",(0,r.jsxs)(s.A,{groupId:"language",children:[(0,r.jsxs)(l.A,{value:"kotlin",label:"Kotlin",default:!0,children:[(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-kotlin",children:"data class City(\n    @PK val id: Int = 0,\n    val name: String,\n    val population: Long\n) : Entity<Int>\n\ndata class User(\n    @PK val id: Int = 0,\n    val email: String,\n    val name: String,\n    @FK val city: City\n) : Entity<Int>\n"})}),(0,r.jsxs)(n.p,{children:["Non-nullable fields (like ",(0,r.jsx)(n.code,{children:"city: City"}),") produce ",(0,r.jsx)(n.code,{children:"INNER JOIN"})," queries. Nullable fields (like ",(0,r.jsx)(n.code,{children:"city: City?"}),") produce ",(0,r.jsx)(n.code,{children:"LEFT JOIN"})," queries. Kotlin's type system maps directly to Storm's null handling."]})]}),(0,r.jsxs)(l.A,{value:"java",label:"Java",children:[(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-java",children:"@Builder(toBuilder = true)\nrecord City(@PK Integer id,\n            String name,\n            long population\n) implements Entity<Integer> {}\n\n@Builder(toBuilder = true)\nrecord User(@PK Integer id,\n            String email,\n            String name,\n            @FK City city\n) implements Entity<Integer> {}\n"})}),(0,r.jsxs)(n.p,{children:["In Java, record components are nullable by default. Use ",(0,r.jsx)(n.code,{children:"@Nonnull"})," on fields that must always have a value. Primitive types (",(0,r.jsx)(n.code,{children:"int"}),", ",(0,r.jsx)(n.code,{children:"long"}),", etc.) are inherently non-nullable."]}),(0,r.jsxs)(n.p,{children:["The ",(0,r.jsx)(n.code,{children:"@Builder"})," annotation is from ",(0,r.jsx)(n.a,{href:"https://projectlombok.org/",children:"Lombok"})," and is optional. It generates a builder that lets you construct entities without specifying the primary key, and creates modified copies via ",(0,r.jsx)(n.code,{children:"toBuilder()"}),". Without Lombok, you can pass ",(0,r.jsx)(n.code,{children:"null"})," as the primary key (e.g., ",(0,r.jsx)(n.code,{children:'new City(null, "Sunnyvale", 161_884)'}),") or define a convenience constructor that omits it. See ",(0,r.jsx)(n.a,{href:"/docs/1.11.5/entities#modifying-entities",children:"Modifying Entities"})," for details."]})]})]}),"\n",(0,r.jsx)(n.p,{children:"These entities map to the following database tables:"}),"\n",(0,r.jsxs)(n.table,{children:[(0,r.jsx)(n.thead,{children:(0,r.jsxs)(n.tr,{children:[(0,r.jsx)(n.th,{children:"Table"}),(0,r.jsx)(n.th,{children:"Columns"})]})}),(0,r.jsxs)(n.tbody,{children:[(0,r.jsxs)(n.tr,{children:[(0,r.jsx)(n.td,{children:(0,r.jsx)(n.code,{children:"city"})}),(0,r.jsxs)(n.td,{children:[(0,r.jsx)(n.code,{children:"id"}),", ",(0,r.jsx)(n.code,{children:"name"}),", ",(0,r.jsx)(n.code,{children:"population"})]})]}),(0,r.jsxs)(n.tr,{children:[(0,r.jsx)(n.td,{children:(0,r.jsx)(n.code,{children:"user"})}),(0,r.jsxs)(n.td,{children:[(0,r.jsx)(n.code,{children:"id"}),", ",(0,r.jsx)(n.code,{children:"email"}),", ",(0,r.jsx)(n.code,{children:"name"}),", ",(0,r.jsx)(n.code,{children:"city_id"})]})]})]})]}),"\n",(0,r.jsxs)(n.p,{children:["Storm automatically appends ",(0,r.jsx)(n.code,{children:"_id"})," to foreign key column names. See ",(0,r.jsx)(n.a,{href:"/docs/1.11.5/entities",children:"Entities"})," for the full set of annotations, naming conventions, and customization options."]}),"\n",(0,r.jsx)(n.h2,{id:"create-the-orm-template",children:"Create the ORM Template"}),"\n",(0,r.jsxs)(n.p,{children:["The ",(0,r.jsx)(n.code,{children:"ORMTemplate"})," is the central entry point for all database operations. It is thread-safe and typically created once at application startup (or provided as a Spring bean). You can create one from a JDBC ",(0,r.jsx)(n.code,{children:"DataSource"}),", ",(0,r.jsx)(n.code,{children:"Connection"}),", or JPA ",(0,r.jsx)(n.code,{children:"EntityManager"}),"."]}),"\n",(0,r.jsxs)(s.A,{groupId:"language",children:[(0,r.jsxs)(l.A,{value:"kotlin",label:"Kotlin",default:!0,children:[(0,r.jsx)(n.p,{children:"Kotlin provides extension properties for concise creation:"}),(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-kotlin",children:"// From a DataSource (most common)\nval orm = dataSource.orm\n\n// From a Connection\nval orm = connection.orm\n\n// From a JPA EntityManager\nval orm = entityManager.orm\n"})})]}),(0,r.jsxs)(l.A,{value:"java",label:"Java",children:[(0,r.jsxs)(n.p,{children:["Use the ",(0,r.jsx)(n.code,{children:"ORMTemplate.of(...)"})," factory methods:"]}),(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-java",children:"// From a DataSource (most common)\nvar orm = ORMTemplate.of(dataSource);\n\n// From a Connection\nvar orm = ORMTemplate.of(connection);\n\n// From a JPA EntityManager\nvar orm = ORMTemplate.of(entityManager);\n"})})]})]}),"\n",(0,r.jsxs)(n.p,{children:["If you are using Spring Boot with one of the starter modules, the ",(0,r.jsx)(n.code,{children:"ORMTemplate"})," bean is created automatically. See ",(0,r.jsx)(n.a,{href:"/docs/1.11.5/spring-integration",children:"Spring Integration"})," for details."]}),"\n",(0,r.jsx)(n.h2,{id:"insert-a-record",children:"Insert a Record"}),"\n",(0,r.jsxs)(s.A,{groupId:"language",children:[(0,r.jsxs)(l.A,{value:"kotlin",label:"Kotlin",default:!0,children:[(0,r.jsx)(n.p,{children:"Storm's Kotlin API provides infix operators for a concise syntax:"}),(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-kotlin",children:'// Insert a city -- the returned object has the database-generated ID\nval city = orm insert City(name = "Sunnyvale", population = 161_884)\n\n// Insert a user that references the city\nval user = orm insert User(\n    email = "[email protected]",\n    name = "Alice",\n    city = city\n)\n'})}),(0,r.jsxs)(n.p,{children:["The ",(0,r.jsx)(n.code,{children:"insert"})," operator sends an INSERT statement, retrieves the auto-generated primary key, and returns a new instance with the key populated. You do not need to set the ",(0,r.jsx)(n.code,{children:"id"})," field yourself when using ",(0,r.jsx)(n.code,{children:"IDENTITY"})," generation (the default)."]})]}),(0,r.jsxs)(l.A,{value:"java",label:"Java",children:[(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-java",children:'var cities = orm.entity(City.class);\nvar users = orm.entity(User.class);\n\n// Insert a city -- the returned object has the database-generated ID\nCity city = cities.insertAndFetch(City.builder()\n        .name("Sunnyvale")\n        .population(161_884)\n        .build());\n\n// Insert a user that references the city\nUser user = users.insertAndFetch(User.builder()\n        .email("[email protected]")\n        .name("Alice")\n        .city(city)\n        .build());\n'})}),(0,r.jsxs)(n.p,{children:["The ",(0,r.jsx)(n.code,{children:"insertAndFetch"})," method sends an INSERT statement, retrieves the auto-generated primary key, and returns a new record with the key populated."]})]})]}),"\n",(0,r.jsx)(n.h2,{id:"read-a-record",children:"Read a Record"}),"\n",(0,r.jsxs)(s.A,{groupId:"language",children:[(0,r.jsx)(l.A,{value:"kotlin",label:"Kotlin",default:!0,children:(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-kotlin",children:'// Find by ID\nval user: User? = orm.entity<User>().findById(userId)\n\n// Find by field value using the metamodel (requires storm-metamodel-processor)\nval user: User? = orm.find(User_.email eq "[email protected]")\n'})})}),(0,r.jsx)(l.A,{value:"java",label:"Java",children:(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-java",children:'// Find by ID\nOptional<User> user = orm.entity(User.class).findById(userId);\n\n// Find by field value using the metamodel (requires storm-metamodel-processor)\nOptional<User> user = orm.entity(User.class)\n    .select()\n    .where(User_.email, EQUALS, "[email protected]")\n    .getOptionalResult();\n'})})})]}),"\n",(0,r.jsxs)(n.p,{children:["When Storm loads a ",(0,r.jsx)(n.code,{children:"User"}),", it automatically joins the ",(0,r.jsx)(n.code,{children:"City"})," table (because ",(0,r.jsx)(n.code,{children:"city"})," is marked with ",(0,r.jsx)(n.code,{children:"@FK"}),") and populates the full ",(0,r.jsx)(n.code,{children:"City"})," object in a single query. There is no N+1 problem."]}),"\n",(0,r.jsx)(n.h2,{id:"update-a-record",children:"Update a Record"}),"\n",(0,r.jsx)(n.p,{children:"Since entities are immutable, you create a new instance with the changed fields and pass it to the update operation."}),"\n",(0,r.jsxs)(s.A,{groupId:"language",children:[(0,r.jsx)(l.A,{value:"kotlin",label:"Kotlin",default:!0,children:(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-kotlin",children:'val updatedUser = orm update user.copy(name = "Alice Johnson")\n'})})}),(0,r.jsx)(l.A,{value:"java",label:"Java",children:(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-java",children:'users.update(new User(user.id(), user.email(), "Alice Johnson", user.city()));\n'})})})]}),"\n",(0,r.jsx)(n.h2,{id:"remove-a-record",children:"Remove a Record"}),"\n",(0,r.jsxs)(s.A,{groupId:"language",children:[(0,r.jsx)(l.A,{value:"kotlin",label:"Kotlin",default:!0,children:(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-kotlin",children:"orm remove user\n"})})}),(0,r.jsx)(l.A,{value:"java",label:"Java",children:(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-java",children:"users.remove(user);\n"})})})]}),"\n",(0,r.jsx)(n.h2,{id:"transactions",children:"Transactions"}),"\n",(0,r.jsx)(n.p,{children:"Wrap multiple operations in a transaction to ensure they succeed or fail together."}),"\n",(0,r.jsxs)(s.A,{groupId:"language",children:[(0,r.jsxs)(l.A,{value:"kotlin",label:"Kotlin",default:!0,children:[(0,r.jsxs)(n.p,{children:["Storm provides a ",(0,r.jsx)(n.code,{children:"transaction"})," block that commits on success and rolls back on exception:"]}),(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-kotlin",children:'transaction {\n    val city = orm insert City(name = "Sunnyvale", population = 161_884)\n    val user = orm insert User(email = "[email protected]", name = "Bob", city = city)\n}\n'})})]}),(0,r.jsxs)(l.A,{value:"java",label:"Java",children:[(0,r.jsxs)(n.p,{children:["With Spring's ",(0,r.jsx)(n.code,{children:"@Transactional"}),":"]}),(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-java",children:"@Transactional\npublic User createUser(String email, String name, City city) {\n    return orm.entity(User.class)\n        .insertAndFetch(User.builder()\n                .email(email)\n                .name(name)\n                .city(city)\n                .build());\n}\n"})})]})]}),"\n",(0,r.jsxs)(n.p,{children:["See ",(0,r.jsx)(n.a,{href:"/docs/1.11.5/transactions",children:"Transactions"})," for programmatic transaction control, propagation modes, and savepoints."]}),"\n",(0,r.jsx)(n.h2,{id:"summary",children:"Summary"}),"\n",(0,r.jsx)(n.p,{children:"You have now seen the core workflow:"}),"\n",(0,r.jsxs)(n.ol,{children:["\n",(0,r.jsxs)(n.li,{children:["Define entities as data classes or records with ",(0,r.jsx)(n.code,{children:"@PK"})," and ",(0,r.jsx)(n.code,{children:"@FK"})," annotations"]}),"\n",(0,r.jsxs)(n.li,{children:["Create an ",(0,r.jsx)(n.code,{children:"ORMTemplate"})," from a ",(0,r.jsx)(n.code,{children:"DataSource"})]}),"\n",(0,r.jsxs)(n.li,{children:["Use ",(0,r.jsx)(n.code,{children:"insert"}),", ",(0,r.jsx)(n.code,{children:"findById"}),", ",(0,r.jsx)(n.code,{children:"update"}),", and ",(0,r.jsx)(n.code,{children:"remove"})," for basic CRUD"]}),"\n"]}),"\n",(0,r.jsx)(n.h2,{id:"next-steps",children:"Next Steps"}),"\n",(0,r.jsxs)(n.ul,{children:["\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.a,{href:"/docs/1.11.5/first-query",children:"First Query"})," -- custom queries, repositories, filtering, and streaming"]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.a,{href:"/docs/1.11.5/entities",children:"Entities"})," -- enumerations, versioning, composite keys, and naming conventions"]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.a,{href:"/docs/1.11.5/spring-integration",children:"Spring Integration"})," -- auto-configuration and dependency injection"]}),"\n"]})]})}function m(e={}){const{wrapper:n}={...(0,i.R)(),...e.components};return n?(0,r.jsx)(n,{...e,children:(0,r.jsx)(h,{...e})}):h(e)}},19365(e,n,t){t.d(n,{A:()=>o});t(96540);var a=t(34164),r=t(47751);const i="tabItem_Ymn6";var s=t(74848);function l({children:e,className:n,hidden:t}){return(0,s.jsx)("div",{role:"tabpanel",className:(0,a.A)(i,n),hidden:t,children:e})}function o({children:e,className:n,value:t}){const{selectedValue:a,lazy:i}=(0,r.uc)(),o=t===a;return!o&&i?null:(0,s.jsx)(l,{className:n,hidden:!o,children:e})}},4865(e,n,t){t.d(n,{A:()=>p});t(96540);var a=t(34164),r=t(17559),i=t(47751),s=t(23104),l=t(92303);const o="tabList__CuJ",c="tabItem_LNqP";var d=t(74848);function u({className:e}){const{selectedValue:n,selectValue:t,tabValues:r,block:l}=(0,i.uc)(),o=[],{blockElementScrollPositionUntilNextRender:u}
1=(0,s.a_)(),h=e=>{const a=e.currentTarget,i=o.indexOf(a),s=r[i].value;s!==n&&(u(a),t(s))},m=e=>{let n=null;switch(e.key){case"Enter":h(e);break;case"ArrowRight":{const t=o.indexOf(e.currentTarget)+1;n=o[t]??o[0];break}case"ArrowLeft":{const t=o.indexOf(e.currentTarget)-1;n=o[t]??o[o.length-1];break}}n?.focus()};return(0,d.jsx)("ul",{role:"tablist","aria-orientation":"horizontal",className:(0,a.A)("tabs",{"tabs--block":l},e),children:r.map(({value:e,label:t,attributes:r})=>(0,d.jsx)("li",{role:"tab",tabIndex:n===e?0:-1,"aria-selected":n===e,ref:e=>{o.push(e)},onKeyDown:m,onClick:h,...r,className:(0,a.A)("tabs__item",c,r?.className,{"tabs__item--active":n===e}),children:t??e},e))})}function h({children:e}){return(0,d.jsx)("div",{className:"margin-top--md",children:e})}function m({className:e,children:n}){return(0,d.jsxs)("div",{className:(0,a.A)(r.G.tabs.container,"tabs-container",o),children:[(0,d.jsx)(u,{className:e}),(0,d.jsx)(h,{children:n})]})}function p(e){const n=(0,l.A)(),t=(0,i.OC)(e);return(0,d.jsx)(i.O_,{value:t,children:(0,d.jsx)(m,{className:e.className,children:(0,i.vT)(e.children)})},String(n))}},47751(e,n,t){t.d(n,{OC:()=>p,O_:()=>y,uc:()=>j,vT:()=>d});var a=t(96540),r=t(56347),i=t(205),s=t(57485),l=t(70679),o=t(31682),c=t(74848);function d(e){return a.Children.toArray(e).filter(e=>"\n"!==e)}function u(e){const{values:n,children:t}=e;return(0,a.useMemo)(()=>{const e=n??function(e){return a.Children.toArray(e).flatMap(e=>{if(!e)return[];if((0,a.isValidElement)(e)&&function(e){const{props:n}=e;return!!n&&"object"==typeof n&&"value"in n}(e))return[e];const n="string"==typeof e.type?e.type:e.type.name;throw new Error(`Docusaurus error: Bad <Tabs> child <${n}>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> should have a unique "value" prop.\nIf you do not want to pass on a "value" prop to the direct children of <Tabs>, you can also pass an explicit <Tabs values={...}> prop.`)}).map(({props:{value:e,label:n,attributes:t,default:a}})=>({value:e,label:n,attributes:t,default:a}))}(t);return function(e){const n=(0,o.XI)(e,(e,n)=>e.value===n.value);if(n.length>0)throw new Error(`Docusaurus error: Duplicate values "${n.map(e=>`'${e.value}'`).join(", ")}" found in <Tabs>. Every value needs to be unique.`)}(e),e},[n,t])}function h({value:e,tabValues:n}){return n.some(n=>n.value===e)}function m({queryString:e=!1,groupId:n}){const t=(0,r.W6)(),i=function({queryString:e=!1,groupId:n}){if("string"==typeof e)return e;if(!1===e)return null;if(!0===e&&!n)throw new Error('Docusaurus error: The <Tabs> component groupId prop is required if queryString=true, because this value is used as the search param name. You can also provide an explicit value such as queryString="my-search-param".');return n??null}({queryString:e,groupId:n});return[(0,s.aZ)(i),(0,a.useCallback)(e=>{if(!i)return;const n=new URLSearchParams(t.location.search);n.set(i,e),t.replace({...t.location,search:n.toString()})},[i,t])]}function p(e){const{defaultValue:n,queryString:t=!1,groupId:r}=e,s=u(e),[o,c]=(0,a.useState)(()=>function({defaultValue:e,tabValues:n}){if(0===n.length)throw new Error("Docusaurus error: the <Tabs> component requires at least one <TabItem> children component");if(e){if(!h({value:e,tabValues:n}))throw new Error(`Docusaurus error: The <Tabs> has a defaultValue "${e}" but none of its children has the corresponding value. Available values are: ${n.map(e=>e.value).join(", ")}. If you intend to show no default tab, use defaultValue={null} instead.`);return e}const t=n.find(e=>e.default)??n[0];if(!t)throw new Error("Unexpected error: 0 tabValues");return t.value}({defaultValue:n,tabValues:s})),[d,p]=m({queryString:t,groupId:r}),[x,j]=function({groupId:e}){const n=function(e){return e?`docusaurus.tab.${e}`:null}(e),[t,r]=(0,l.Dv)(n);return[t,(0,a.useCallback)(e=>{n&&r.set(e)},[n,r])]}({groupId:r}),y=(()=>{const e=d??x;return h({value:e,tabValues:s})?e:null})();(0,i.A)(()=>{y&&c(y)},[y]);return{selectedValue:o,selectValue:(0,a.useCallback)(e=>{if(!h({value:e,tabValues:s}))throw new Error(`Can't select invalid tab value=${e}`);c(e),p(e),j(e)},[p,j,s]),tabValues:s,lazy:e.lazy??!1,block:e.block??!1}}const x=(0,a.createContext)(null);function j(){const e=a.useContext(x);if(!e)throw new Error("useTabsContext() must be used within a Tabs component");return e}function y(e){return(0,c.jsx)(x.Provider,{value:e.value,children:e.children})}},28453(e,n,t){t.d(n,{R:()=>s,x:()=>l});var a=t(96540);const r={},i=a.createContext(r);function s(e){const n=a.useContext(i);return a.useMemo(function(){return"function"==typeof e?e(n):{...n,...e}},[n,e])}function l(e){let n;return n=e.disableParentContext?"function"==typeof e.components?e.components(r):e.components||r:s(e.components),a.createElement(i.Provider,{value:n},e.children)}}}]);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.