From 6113ed226f65d1feea9118659a8f1ff3efc6b0e3 Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Fri, 31 Jul 2026 15:09:22 +0200 Subject: [PATCH 01/22] CQL w/ live snippets --- .../db/data/sap.capire.bookshop-Addresses.csv | 5 ++ .../db/data/sap.capire.bookshop-Authors.csv | 11 +-- .../db/data/sap.capire.bookshop-Books.csv | 1 + .../db/data/sap.capire.bookshop-Towns.csv | 5 ++ .../templates/bookshop/db/schema.cds | 17 ++++ cds/cql.md | 79 +++++++++++-------- 6 files changed, 80 insertions(+), 38 deletions(-) create mode 100644 .vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Addresses.csv create mode 100644 .vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Towns.csv diff --git a/.vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Addresses.csv b/.vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Addresses.csv new file mode 100644 index 0000000000..bac27500cf --- /dev/null +++ b/.vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Addresses.csv @@ -0,0 +1,5 @@ +ID,street,town_ID +1,6 Place des Vosges,1 +2,Church Street,2 +3,North Street,3 +4,King Street,4 \ No newline at end of file diff --git a/.vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Authors.csv b/.vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Authors.csv index 9b418c17f2..d0f9f0c48c 100644 --- a/.vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Authors.csv +++ b/.vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Authors.csv @@ -1,5 +1,6 @@ -ID,name,dateOfBirth,placeOfBirth,dateOfDeath,placeOfDeath -101,Emily Brontë,1818-07-30,"Thornton, Yorkshire",1848-12-19,"Haworth, Yorkshire" -107,Charlotte Brontë,1818-04-21,"Thornton, Yorkshire",1855-03-31,"Haworth, Yorkshire" -150,Edgar Allen Poe,1809-01-19,"Boston, Massachusetts",1849-10-07,"Baltimore, Maryland" -170,Richard Carpenter,1929-08-14,"King’s Lynn, Norfolk",2012-02-26,"Hertfordshire, England" +ID,name,dateOfBirth,placeOfBirth,dateOfDeath,placeOfDeath,address_ID +10,Victor Hugo,1802-02-26,"Besançon, Franche-Comté",1885-05-22,"Paris, Île-de-France",1 +101,Emily Brontë,1818-07-30,"Thornton, Yorkshire",1848-12-19,"Haworth, Yorkshire",2 +107,Charlotte Brontë,1818-04-21,"Thornton, Yorkshire",1855-03-31,"Haworth, Yorkshire",2 +150,Edgar Allen Poe,1809-01-19,"Boston, Massachusetts",1849-10-07,"Baltimore, Maryland",3 +170,Richard Carpenter,1929-08-14,"King’s Lynn, Norfolk",2012-02-26,"Hertfordshire, England",4 diff --git a/.vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Books.csv b/.vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Books.csv index d9cc9ee2ee..87ff63081e 100644 --- a/.vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Books.csv +++ b/.vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Books.csv @@ -4,3 +4,4 @@ ID,title,descr,author_ID,stock,price,currency_code,genre_ID 251,The Raven,"""The Raven"" is a narrative poem by American writer Edgar Allan Poe. First published in January 1845, the poem is often noted for its musicality, stylized language, and supernatural atmosphere. It tells of a talking raven's mysterious visit to a distraught lover, tracing the man's slow fall into madness. The lover, often identified as being a student, is lamenting the loss of his love, Lenore. Sitting on a bust of Pallas, the raven seems to further distress the protagonist with its constant repetition of the word ""Nevermore"". The poem makes use of folk, mythological, religious, and classical references.",150,333,13.13,USD,16aaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa 252,Eleonora,"""Eleonora"" is a short story by Edgar Allan Poe, first published in 1842 in Philadelphia in the literary annual The Gift. It is often regarded as somewhat autobiographical and has a relatively ""happy"" ending.",150,555,14,USD,15aaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa 271,Catweazle,"Catweazle is a British fantasy television series, starring Geoffrey Bayldon in the title role, and created by Richard Carpenter for London Weekend Television. The first series, produced and directed by Quentin Lawrence, was screened in the UK on ITV in 1970. The second series, directed by David Reid and David Lane, was shown in 1971. Each series had thirteen episodes, most but not all written by Carpenter, who also published two books based on the scripts.",170,22,150,JPY,13aaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa +281,Les Misérables,"Les Misérables (French pronunciation: ​[le mizeʁabl]) is a French historical novel by Victor Hugo, first published in 1862, that is considered one of the greatest novels of the 19th century. In the English-speaking world, the novel is usually referred to by its original French title, although it is sometimes translated as The Miserable Ones, The Wretched, or The Poor Ones. The story examines the nature of law and grace, and expounds upon the history of France, the architecture and urban design of Paris, politics, moral philosophy, antimonarchism, justice, religion, and the types and nature of romantic and familial love.",10,33,20.20,EUR,12aaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa \ No newline at end of file diff --git a/.vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Towns.csv b/.vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Towns.csv new file mode 100644 index 0000000000..882b2840a3 --- /dev/null +++ b/.vitepress/lib/cds-playground/templates/bookshop/db/data/sap.capire.bookshop-Towns.csv @@ -0,0 +1,5 @@ +ID,name,zip,country +1,Paris,75000,France +2,Thornton,NN14,UK +3,Boston,02108,USA +4,King’s Lynn,PE30,UK \ No newline at end of file diff --git a/.vitepress/lib/cds-playground/templates/bookshop/db/schema.cds b/.vitepress/lib/cds-playground/templates/bookshop/db/schema.cds index 8c510a3599..763744a288 100644 --- a/.vitepress/lib/cds-playground/templates/bookshop/db/schema.cds +++ b/.vitepress/lib/cds-playground/templates/bookshop/db/schema.cds @@ -27,6 +27,23 @@ entity Authors { age = years_between(dateOfBirth, coalesce(dateOfDeath, date( $now ))); } +extend Authors with { + address : Association to Addresses; +} + +entity Addresses { + key ID : Integer; + street : String; + town : Association to Towns; +} + +entity Towns { + key ID : Integer; + name : String; + zip : String; + country : String; +} + /** Hierarchically organized Code List for Genres */ entity Genres : cuid, sap.common.CodeList { parent : Association to Genres; diff --git a/cds/cql.md b/cds/cql.md index bf8d5154fd..563eef36f8 100644 --- a/cds/cql.md +++ b/cds/cql.md @@ -17,10 +17,10 @@ CDS Query Language (CQL) is based on standard SQL, which it enhances by... CQL allows to put projections, that means, the `SELECT` clause, behind the `FROM` clause enclosed in curly braces. For example, the following are equivalent: -```sql +```cds live SELECT name, address.street from Authors ``` -```sql +```cds live SELECT from Authors { name, address.street } ``` @@ -30,7 +30,7 @@ SELECT from Authors { name, address.street } Postfix projections can be appended to any column referring to a struct element or an association and hence be nested. This allows **expand** results along associations and hence read deeply structured documents: -```sql +```cds live SELECT from Authors { name, address { street, town { name, country }} }; @@ -67,7 +67,7 @@ Nested Expands following _to-many_ associations are not supported. As the name of the struct element or association preceding the postfix projection appears in the result set, an alias can be provided for it: -```sql +```cds live SELECT from Authors { name, address as residence { street, town as city { name, country }} }; @@ -95,7 +95,7 @@ Nested Expands can contain expressions. In addition, it's possible to define new structures that aren't present in the data source. In this case an alias is mandatory and is placed *behind* the `{…}`: -```sql +```cds live SELECT from Books { title, author { name, dateOfDeath - dateOfBirth as age }, @@ -125,7 +125,7 @@ results = [ Put a **`"."`** before the opening brace to **inline** the target elements and avoid writing lengthy lists of paths to read several elements from the same target. For example: -```sql +```cds live SELECT from Authors { name, address.{ street, town.{ name, country }} }; @@ -133,7 +133,7 @@ SELECT from Authors { … is equivalent to: -```sql +```cds live SELECT from Authors { name, address.street, @@ -144,7 +144,7 @@ SELECT from Authors { Nested Inlines can contain expressions: -```sql +```cds live SELECT from Books { title, author.{ @@ -156,7 +156,7 @@ SELECT from Books { The previous example is equivalent to the following: -```sql +```cds live SELECT from Books { title, author.name, @@ -172,7 +172,7 @@ Within postfix projections, the `*` operator queries are handled slightly differ #### Example: -```sql +```cds live SELECT from Books { *, author.name as author } ``` @@ -183,7 +183,7 @@ Queries like in our example, would result in duplicate element effects for `auth Use the `excluding` clause in combination with `SELECT *` to select all elements except for the ones listed in the exclude list. -```sql +```cds live SELECT from Books { * } excluding { author } ``` @@ -222,11 +222,11 @@ SELECT * from Boo --> { foo, car } If the `*` selector is used following an association, it selects all elements of the association target. For example, the following queries are equivalent: -```sql +```cds live SELECT from Books { title, author { * } } ``` -```sql -SELECT from Books { title, author { ID, name, dateOfBirth, … } } +```cds live +SELECT from Books { title, author { ID, name, dateOfBirth, /*…*/ } } ``` @@ -239,7 +239,7 @@ SELECT from Authors { name, struc } ``` The `excluding` clause can also be used for Nested Expands: -```sql +```cds live SELECT from Books { title, author { * } excluding { dateOfDeath, placeOfDeath } } ``` @@ -249,13 +249,15 @@ SELECT from Books { title, author { * } excluding { dateOfDeath, placeOfDeath } The expansion of `*` in Nested Inlines is analogous. The following queries are equivalent: -```sql +```cds live SELECT from Books { title, author.{ * } } -SELECT from Books { title, author.{ ID, name, dateOfBirth, … } } +``` +```cds live +SELECT from Books { title, author.{ ID, name, dateOfBirth, /*…*/ } } ``` The `excluding` clause can also be used for Nested Inlines: -```sql +```cds live SELECT from Books { title, author.{ * } excluding { dateOfDeath, placeOfDeath } } ``` @@ -265,19 +267,23 @@ SELECT from Books { title, author.{ * } excluding { dateOfDeath, placeOfDeath } Use path expressions to navigate along associations and/or struct elements in any of the SQL clauses as follows: In `from` clauses: -```sql +```cds live SELECT from Authors[name='Emily Brontë'].books; -SELECT from Books:authors.towns; +``` +```cds live +SELECT from Books:author.address.town; ``` In `select` clauses: -```sql +```cds live SELECT title, author.name from Books; +``` +```cds live SELECT *, author.address.town.name from Books; ``` In `where` clauses: -```sql +```cds live SELECT from Books where author.name='Emily Brontë' ``` @@ -289,12 +295,14 @@ The same is valid for `group by`, `having`, and `order by`. Path expressions in from clauses allow to fetch only those entries from a target entity, which are associated to a parent entity. They unfold to _SEMI JOINS_ in plain SQL queries. For example, the previous mentioned queries would unfold to the following plain SQL counterparts: ```sql +-- plain SQL SELECT * from Books WHERE EXISTS ( SELECT 1 from Authors WHERE Authors.ID = Books.author_ID AND Authors.name='Emily Brontë' ); ``` ```sql +-- plain SQL SELECT * from Towns WHERE EXISTS ( SELECT 1 from Authors WHERE Authors.town_ID = Towns.ID AND EXISTS ( SELECT 1 from Books WHERE Books.author_ID = Authors.ID @@ -333,7 +341,7 @@ All column references get qualified → in contrast to plain SQL joins there' Append infix filters to associations in path expressions to narrow the resulting joins. For example: -```sql +```cds live SELECT books[genre='Mystery'].title from Authors WHERE name='Agatha Christie' ``` @@ -348,7 +356,7 @@ WHERE Authors.name='Agatha Christie'; If an infix filter effectively reduces the cardinality of a *to-many* association to *one*, make this explicit with: -```sql +```cds SELECT name, books[1: favorite=true].title from Authors ``` @@ -361,28 +369,29 @@ that the specified condition reduces the result to a single entry. Use a filtered path expression to test if any element of the associated collection matches the given filter: -```sql -SELECT FROM Authors {name} WHERE EXISTS books[year = 2000] +```cds live +SELECT FROM Authors {name} WHERE EXISTS books[stock = 11] ``` ...unfolds to: ```sql +-- plain SQL SELECT name FROM Authors WHERE EXISTS ( SELECT 1 FROM Books WHERE Books.author_id = Authors.id - AND Books.year = 2000 + AND Books.stock = 11 ) ``` Exists predicates can be nested: -```sql +```cds SELECT FROM Authors { name } WHERE EXISTS books[year = 2000 and EXISTS pages[wordcount > 1000]] ``` A path with several associations is rewritten as nested exists predicates. The previous query is equivalent to the following query. -```sql +```cds SELECT FROM Authors { name } WHERE EXISTS books[year = 2000].pages[wordcount > 1000] ``` @@ -397,13 +406,17 @@ Paths *inside* the filter are not yet supported. There are two different constructs commonly called casts. SQL casts and CDL casts. The former produces SQL casts when rendered into SQL, whereas the latter does not: -```sql -SELECT cast (foo+1 as Decimal) as bar from Foo; -- standard SQL -SELECT from Foo { foo+1 as bar : Decimal }; -- CDL-style +```cds live +// standard SQL +SELECT price, cast (price+1 as Integer) as pr from Books; +``` +```cds live +// CDL-style +SELECT from Books { price, price+1 as pr : Integer }; ``` [Learn more about CDL type definitions](./cdl#types){.learn-more} -Use SQL casts when you actually want a cast in SQL. CDL casts are useful for expressions such as `foo+1` as the compiler does not deduce types. +Use SQL casts when you actually want a cast in SQL. CDL casts are useful for expressions such as `price+1` as the compiler does not deduce types. For the OData backend, by specifying a type, the compiler will also assign the correct EDM type in the generated EDM(X) files. ::: tip From 07e19b582efb89cc2a4839958c0c1a5c13e3e555 Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Tue, 4 Aug 2026 10:33:26 +0200 Subject: [PATCH 02/22] Multimodel support in live queries --- cds/cql.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/cds/cql.md b/cds/cql.md index 563eef36f8..1467f82041 100644 --- a/cds/cql.md +++ b/cds/cql.md @@ -190,7 +190,7 @@ SELECT from Books { * } excluding { author } The effect is about **late materialization** of signatures and staying open to late extensions. For example, assume the following definitions: -```cds +```cds [FooBar] entity Foo { foo : String; bar : String; car : String; } entity Bar as select from Foo excluding { bar }; entity Boo as select from Foo { foo, car }; @@ -198,22 +198,26 @@ entity Boo as select from Foo { foo, car }; A `SELECT * from Bar` would result into the same as a query of `Boo`: -```sql -SELECT * from Bar --> { foo, car } -SELECT * from Boo --> { foo, car } +```cds live [FooBar] +SELECT * from Bar //> { foo, car } +``` +```cds live [FooBar] +SELECT * from Boo //> { foo, car } ``` Now, assume a consumer of that package extends the definitions as follows: -```cds +```cds [FooBarBoo] extend Foo with { boo : String; } ``` With that, queries on `Bar` and `Boo` would return different results: -```sql -SELECT * from Bar --> { foo, car, boo } -SELECT * from Boo --> { foo, car } +```cds live [FooBarBoo] +SELECT * from Bar //> { foo, car, boo } +``` +```cds live [FooBarBoo] +SELECT * from Boo //> { foo, car } ``` From f9d673ba98d329e27e9485449cf904f133585395 Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Tue, 4 Aug 2026 13:45:52 +0200 Subject: [PATCH 03/22] Show model next to query --- .../restoreCodeGroupPreferences.js | 4 + .../lib/code-groups/useCodeGroupSync.ts | 12 +++ .../components/cds-playground/LiveCode.vue | 86 ++++++++++++++++++- 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js b/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js index 1268e3823b..f7cd50bad1 100644 --- a/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js +++ b/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js @@ -44,6 +44,10 @@ if (tabs.length === 0) return + // Skip code groups unrelated to the OS/runtime/cloud-runtime dimensions (e.g. file-path + // tabs), otherwise they'd be forced back to their first tab on every re-init. + if (!tabs.some((tab) => getTabDimension(tab))) return // eslint-disable-line no-undef + const selectedTab = getBestTab(tabs, activeTabs) // eslint-disable-line no-undef const selectedIndex = tabs.indexOf(selectedTab) diff --git a/.vitepress/lib/code-groups/useCodeGroupSync.ts b/.vitepress/lib/code-groups/useCodeGroupSync.ts index 8efdbbd3c7..f2a9544e1a 100644 --- a/.vitepress/lib/code-groups/useCodeGroupSync.ts +++ b/.vitepress/lib/code-groups/useCodeGroupSync.ts @@ -12,6 +12,7 @@ import { addActiveTab, getActiveTabsByDimension, getBestTab, + getTabDimension, setActiveTab, tabsMatch } from './shared.js' @@ -47,6 +48,11 @@ function findCodeGroups(): CodeGroupInfo[] { function applyPreference(codeGroup: CodeGroupInfo): void { const { element, tabs } = codeGroup + + // Skip code groups unrelated to the OS/runtime/cloud-runtime dimensions (e.g. file-path + // tabs), otherwise they'd be forced back to their first tab on every re-init. + if (!tabs.some((tab) => getTabDimension(tab))) return + const selectedTab = getBestTab( tabs, getActiveTabsByDimension((window as any).__CODE_GROUP_ACTIVE_TABS__) @@ -88,6 +94,12 @@ function handleDocumentClick(event: Event): void { const tabLabel = (label.textContent || '').trim() if (!tabLabel) return + // Only tabs that belong to a recognized dimension (OS/runtime/cloud-runtime) should be + // synced across the page. Otherwise unrelated code groups sharing a "/" path segment + // (e.g. "srv/admin-service.cds" vs. "srv/cat-service.cds") get fuzzy-matched and forced + // into the wrong active tab. + if (!getTabDimension(tabLabel)) return + const clickedRect = label.getBoundingClientRect() syncTabs(tabLabel) diff --git a/.vitepress/theme/components/cds-playground/LiveCode.vue b/.vitepress/theme/components/cds-playground/LiveCode.vue index d10b51179f..fdd63df56c 100644 --- a/.vitepress/theme/components/cds-playground/LiveCode.vue +++ b/.vitepress/theme/components/cds-playground/LiveCode.vue @@ -28,6 +28,14 @@
+
@@ -56,15 +64,40 @@
+ +
+
+ +
+ +
+
+ + {{ tab.kind }} + +
+
+
+ ', diff --git a/.vitepress/theme/components/cds-playground/LiveCode.vue b/.vitepress/theme/components/cds-playground/LiveCode.vue index 87935b2aab..a39cc0c773 100644 --- a/.vitepress/theme/components/cds-playground/LiveCode.vue +++ b/.vitepress/theme/components/cds-playground/LiveCode.vue @@ -74,7 +74,7 @@ import { computed, onMounted, ref, useId } from 'vue' import MonacoEditor from './MonacoEditor.vue' import { useData } from 'vitepress' import play from '/icons/play.svg?url&raw' -import { runners } from './runners' +import { runners, runWithModel } from './runners' import highlighter from './highlighter' import templates from 'virtual:templates' @@ -95,6 +95,10 @@ const props = defineProps({ type: String, default: 'js' }, + modelSource: { + type: String, + default: '' + }, onEvaluate: { type: Function } @@ -111,10 +115,15 @@ const evalStatus = ref(null) // the model the query runs against, shown on demand so it doesn't clutter the snippet const modelVisible = ref(false) -const modelTabs = computed(() => (templates.bookshop ?? []) - .filter(file => file.path.endsWith('.cds')) - .sort((f1, f2) => f1.path.localeCompare(f2.path)) - .map(file => ({ key: `${uid}-model-${file.path}`, kind: 'cds', name: file.path, value: file.content }))) +const modelTabs = computed(() => { + if (props.modelSource) { + return [{ key: `${uid}-model-custom`, kind: 'cds', name: 'model', value: props.modelSource }] + } + return (templates.bookshop ?? []) + .filter(file => file.path.endsWith('.cds')) + .sort((f1, f2) => f1.path.localeCompare(f2.path)) + .map(file => ({ key: `${uid}-model-${file.path}`, kind: 'cds', name: file.path, value: file.content })) +}) // eval tabs (if any) come first, model tabs are appended at the end const combinedTabs = computed(() => [ @@ -194,7 +203,8 @@ async function evaluate() { } queryResult.value = null try { - const exec = props.onEvaluate ?? runners[props.language] + const exec = props.onEvaluate + ?? (props.modelSource ? (q) => runWithModel(q, props.modelSource) : runners[props.language]) if (!exec) throw new Error(`No runner found for language: ${props.language}. Available runners: ${Object.keys(runners).join(', ')}`) const result = await exec(queryText.value) tabs.value = formatTabs(result).filter(({ value }) => value) diff --git a/.vitepress/theme/components/cds-playground/cds-worker.js b/.vitepress/theme/components/cds-playground/cds-worker.js new file mode 100644 index 0000000000..17da16ee5b --- /dev/null +++ b/.vitepress/theme/components/cds-playground/cds-worker.js @@ -0,0 +1,61 @@ +function simpleSqlFormat(sql) { + return sql + .replace(/\b(select|from|where|group by|order by|having|limit|offset|join|left join|right join|inner join|outer join)\b/gi, "\n$1") + .replace(/\b(and|or)\b/gi, "\n $1") + .replace(/,\s*/g, ",\n ") + .replace(/\n{2,}/g, "\n") + .trim(); +} + +const sqlLog = []; + +function injectLogger(sqlite) { + const { prototype } = sqlite().constructor; + const { prepare: original } = prototype; + prototype.prepare = function prepare(sql) { + sqlLog.push(sql); + return original.call(this, sql); + } +} + +let cds; +let initialized = false; + +async function init(modelSource) { + cds = (await import('@sap/cds')).default; + const sqlite = (await import('better-sqlite3')).default; + + await sqlite.initialized; + injectLogger(sqlite); + + const csn = cds.compile({ 'model.cds': modelSource }); + cds.model = csn; + + cds.db = await cds.connect.to('db'); + + const csvs = {} + await cds.deploy(csn, null, csvs).to(cds.db); + initialized = true; +} + +self.onmessage = async ({ data: { type, id, payload } }) => { + try { + if (type === 'init') { + await init(payload.modelSource); + self.postMessage({ type: 'ready' }); + } else if (type === 'query') { + if (!initialized) throw new Error('Worker not initialized'); + sqlLog.length = 0; + const cqn = cds.ql(payload.query); + const result = await cds.db.run(cqn); + const formatted = sqlLog.map(simpleSqlFormat).join('\n\n-------\n'); + self.postMessage({ type: 'result', id, result: [ + { value: result, kind: 'json', name: 'Result' }, + { value: formatted, kind: 'sql', name: 'SQL' }, + { value: cqn, kind: 'json', name: 'CQN' }, + ]}); + } + } catch (err) { + self.postMessage({ type: 'error', id, error: err.message ?? String(err) }); + } +}; diff --git a/.vitepress/theme/components/cds-playground/runners.js b/.vitepress/theme/components/cds-playground/runners.js index 7b7683e7d2..cc2939ceae 100644 --- a/.vitepress/theme/components/cds-playground/runners.js +++ b/.vitepress/theme/components/cds-playground/runners.js @@ -91,9 +91,44 @@ async function cdsQL(query) { ]; } +// Worker pool: one worker per model source string, shared across all LiveCode instances +const workerPool = new Map(); + +function getOrCreateWorker(modelSource) { + if (workerPool.has(modelSource)) return workerPool.get(modelSource); + const worker = new Worker(new URL('./cds-worker.js', import.meta.url), { type: 'module' }); + const initPromise = new Promise((resolve, reject) => { + worker.addEventListener('message', function once(e) { + if (e.data.type !== 'ready' && e.data.type !== 'error') return; + worker.removeEventListener('message', once); + e.data.type === 'ready' ? resolve() : reject(new Error(e.data.error)); + }); + worker.postMessage({ type: 'init', payload: { modelSource } }); + }); + const entry = { worker, initPromise }; + workerPool.set(modelSource, entry); + return entry; +} + +async function runWithModel(query, modelSource) { + const { worker, initPromise } = getOrCreateWorker(modelSource); + await initPromise; + return new Promise((resolve, reject) => { + const id = crypto.randomUUID(); + function handler(e) { + if (e.data.id !== id) return; + worker.removeEventListener('message', handler); + e.data.type === 'error' ? reject(new Error(e.data.error)) : resolve(e.data.result); + } + worker.addEventListener('message', handler); + worker.postMessage({ type: 'query', id, payload: { query } }); + }); +} + export { evalJS, cdsQL, + runWithModel, } export const runners = { diff --git a/cds/cql.md b/cds/cql.md index 3745669589..e590ceb54e 100644 --- a/cds/cql.md +++ b/cds/cql.md @@ -190,7 +190,7 @@ SELECT from Books { * } excluding { author } The effect is about **late materialization** of signatures and staying open to late extensions. For example, assume the following definitions: -```cds [FooBar] +```cds [FooBar, data: {'data/Foo.csv': 'foo,bar,car\nFoo,Bar,Car'}] entity Foo { foo : String; bar : String; car : String; } entity Bar as select from Foo excluding { bar }; entity Boo as select from Foo { foo, car }; @@ -216,7 +216,7 @@ With that, queries on `Bar` and `Boo` would return different results: ```cds live [FooBarBoo] SELECT * from Bar //> { foo, car, boo } ``` -```cds live [FooBar] +```cds live [FooBarBoo] SELECT * from Boo //> { foo, car } ``` diff --git a/package-lock.json b/package-lock.json index ca24d7ec8d..0232065f34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "globals": "^17.4.0", "htmlparser2": "^12", "monaco-editor": "^0", + "patch-package": "^8.0.1", "sass": "^1.62.1", "vite-plugin-cds": "^0.3.1", "vitepress": "2.0.0-alpha.18" @@ -2128,6 +2129,13 @@ "vue": "^3.5.0" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2202,6 +2210,22 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2281,6 +2305,19 @@ "node": "18 || 20 || >=22" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2291,6 +2328,25 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2333,6 +2389,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -2382,6 +2455,42 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -2443,7 +2552,6 @@ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -2500,6 +2608,24 @@ "license": "MIT", "peer": true }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3023,6 +3149,19 @@ "node": ">=16.0.0" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3063,6 +3202,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -3136,6 +3285,21 @@ "node": ">= 0.8" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3240,6 +3404,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3442,6 +3636,22 @@ "node": ">= 0.10" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3465,6 +3675,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -3472,13 +3692,32 @@ "dev": true, "license": "MIT" }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/json-buffer": { "version": "3.0.1", @@ -3496,6 +3735,26 @@ "license": "MIT", "peer": true }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -3504,6 +3763,29 @@ "license": "MIT", "peer": true }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3515,6 +3797,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4668,6 +4960,33 @@ ], "license": "MIT" }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -4711,6 +5030,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minisearch": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", @@ -4801,6 +5130,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -4850,6 +5189,23 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4913,6 +5269,36 @@ "node": ">= 0.8" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -4937,7 +5323,6 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -5288,6 +5673,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -5301,7 +5704,6 @@ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -5315,7 +5717,6 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -5416,6 +5817,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5462,6 +5873,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tabbable": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", @@ -5486,6 +5910,29 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -5716,6 +6163,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5950,7 +6407,6 @@ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "isexe": "^2.0.0" }, diff --git a/package.json b/package.json index 4b53a2679c..d6b1bea021 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "globals": "^17.4.0", "htmlparser2": "^12", "monaco-editor": "^0", + "patch-package": "^8.0.1", "sass": "^1.62.1", "vite-plugin-cds": "^0.3.1", "vitepress": "2.0.0-alpha.18" diff --git a/patches/vite-plugin-cds+0.3.5.patch b/patches/vite-plugin-cds+0.3.5.patch new file mode 100644 index 0000000000..8dcc2e4186 --- /dev/null +++ b/patches/vite-plugin-cds+0.3.5.patch @@ -0,0 +1,48 @@ +diff --git a/node_modules/vite-plugin-cds/node/vite.js b/node_modules/vite-plugin-cds/node/vite.js +index 1245549..a28263d 100644 +--- a/node_modules/vite-plugin-cds/node/vite.js ++++ b/node_modules/vite-plugin-cds/node/vite.js +@@ -113,7 +113,43 @@ export function nodeVite() { + if (/node_modules\/vite\/dist\/client\/env.mjs$/.test(id)) { + return `${windowBootstrap}\n${code}`; + } ++ // @sap/cds's lazify() calls the real Node `module.require`, which bundler-emulated ++ // `module` objects don't implement; route it through the polyfilled global `require` instead. ++ // Matched by content only (not `id`) since the same file can be bundled under multiple ++ // resolved ids (e.g. once for the client build, once for the worker sub-build) and the ++ // literal path segment isn't reliably present in every id variant. ++ if (code.includes('module.require(id)')) { ++ return code.replaceAll('module.require(id)', 'require(id)'); ++ } ++ // @sap/cds's Query.init() derives the CQN keyword ("SELECT", "INSERT", ...) from the ++ // query-builder class's own `.name` at module-init time (`kind = self.name`), then uses ++ // `q[kind] = x` to build the CQN object. Rolldown's isolated worker sub-build can lose ++ // these classes' `.name` (resolving to `""`), corrupting every query built there (e.g. ++ // dropping the `from` clause). Make `kind` an explicit parameter instead of relying on it. ++ if (code.includes('static init() {') && code.includes('kind = self.name')) { ++ return code.replace( ++ 'static init() {\n const self = this, kind = self.name', ++ 'static init(kind = this.name) {\n const self = this' ++ ); ++ } ++ const initCall = code.match(/module\.exports = (SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|UPSERT)\.init\(\)/); ++ if (initCall) { ++ return code.replace(initCall[0], `module.exports = ${initCall[1]}.init('${initCall[1]}')`); ++ } ++ return null; ++ }, ++ renderChunk(code) { ++ // Rolldown's CJS/ESM interop can synthesize its own lazy-require wrapper (a Proxy calling ++ // `.require()`) for @sap/cds's lazify() pattern, in addition to (or instead ++ // of) the literal source text handled above. Catch it here, post-bundling, by matching the ++ // generic `.require()` member-call shape, which real Node's `module.require` ++ // is the only realistic source of in this codebase. ++ const re = /\b([a-zA-Z_$][\w$]*)\.require\(([a-zA-Z_$][\w$]*)\)/g; ++ if (re.test(code)) { ++ return { code: code.replace(re, (m, obj, arg) => `require(${arg})`), map: null }; ++ } + return null; + }, + }; + } ++ From df3b980b22749ee517c4010426f8c1f1ddf906f6 Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Tue, 11 Aug 2026 13:30:26 +0200 Subject: [PATCH 10/22] Show sample data --- .vitepress/config.js | 2 +- .vitepress/lib/cds-playground/md-live-code.ts | 80 ++++++++++++++----- .../components/cds-playground/LiveCode.vue | 30 +++++-- .../components/cds-playground/cds-worker.js | 7 +- .../components/cds-playground/runners.js | 13 +-- cds/cql.md | 14 +++- 6 files changed, 109 insertions(+), 37 deletions(-) diff --git a/.vitepress/config.js b/.vitepress/config.js index 8b0bf0eddf..168e1d124d 100644 --- a/.vitepress/config.js +++ b/.vitepress/config.js @@ -98,7 +98,7 @@ const config = defineConfig({ build: { chunkSizeWarningLimit: 6000, // chunk for local search index dominates }, - // cds.worker.js is constructed with `type: 'module'`; match that at build time so its + // cds-worker.js is constructed with `type: 'module'`; match that at build time so its // dynamic import('@sap/cds') is emitted as native ESM instead of an iife require() shim worker: { format: 'es', diff --git a/.vitepress/lib/cds-playground/md-live-code.ts b/.vitepress/lib/cds-playground/md-live-code.ts index 7a94c5fc3e..9725bf8f76 100644 --- a/.vitepress/lib/cds-playground/md-live-code.ts +++ b/.vitepress/lib/cds-playground/md-live-code.ts @@ -26,12 +26,54 @@ import { enabled } from '.' * Named model definitions (static, non-live): * - ```cds [FooBar] — defines a named model; rendered as a plain code block * - ```cds [FooBarBoo: FooBar] — extends FooBar; combined source is resolved at render time + * - ```cds [FooBar, data: FooData] — attaches a named CSV data set to the model + * + * Named CSV data sets (static, non-live): + * - ```csv [FooData: data/Foo.csv] — defines a named data set; rendered as a plain code block + * - ```csv hidden [FooData: data/Foo.csv] — same, but suppressed from output (not rendered) + * + * CSV and model blocks may appear anywhere on the page — they are collected in a full token pass + * before any fence is rendered, so forward references work. */ const MODEL_ARG_RE = /^\[.+\]$/ -function buildModelMap(tokens: any[]): Record { - const raw: Record = {} +interface ModelDef { source: string; csvs?: Record } + +function parseBracketKV(inner: string): { name: string; base?: string; data?: string } { + const commaIdx = inner.indexOf(',') + const namePart = commaIdx === -1 ? inner.trim() : inner.slice(0, commaIdx).trim() + const colonIdx = namePart.indexOf(':') + const name = colonIdx === -1 ? namePart : namePart.slice(0, colonIdx).trim() + const base = colonIdx === -1 ? undefined : namePart.slice(colonIdx + 1).trim() + let data: string | undefined + if (commaIdx !== -1) { + const dataMatch = inner.slice(commaIdx + 1).match(/\bdata\s*:\s*(\S+)/) + if (dataMatch) data = dataMatch[1] + } + return { name, base, data } +} + +function buildDataMap(tokens: any[]): Record> { + const result: Record> = {} + for (const token of tokens) { + if (token.type !== 'fence') continue + const bracketMatch = token.info.match(/\[([^\]]+)\]/) + if (!bracketMatch) continue + const [lang] = token.info.slice(0, bracketMatch.index).trim().split(/\s+/) + if (lang !== 'csv') continue + const inner = bracketMatch[1] + const colonIdx = inner.indexOf(':') + if (colonIdx === -1) continue + const name = inner.slice(0, colonIdx).trim() + const path = inner.slice(colonIdx + 1).trim() + result[name] = { [path]: token.content.trim() } + } + return result +} + +function buildModelMap(tokens: any[], dataMap: Record>): Record { + const raw: Record }> = {} for (const token of tokens) { if (token.type !== 'fence') continue // Match the bracket first since its content may contain spaces (e.g. "[Foo: Bar]"), @@ -43,19 +85,18 @@ function buildModelMap(tokens: any[]): Record { if (lang !== 'cds') continue // Only pick up non-live model definition blocks if (before.includes('live')) continue - const inner = bracketMatch[1] - const colonIdx = inner.indexOf(':') - const name = colonIdx === -1 ? inner.trim() : inner.slice(0, colonIdx).trim() - const base = colonIdx === -1 ? undefined : inner.slice(colonIdx + 1).trim() - raw[name] = { source: token.content.trim(), base } + const { name, base, data } = parseBracketKV(bracketMatch[1]) + raw[name] = { source: token.content.trim(), base, csvs: data ? dataMap[data] : undefined } } - const resolved: Record = {} - function resolve(name: string): string { + const resolved: Record = {} + function resolve(name: string): ModelDef { if (name in resolved) return resolved[name] const def = raw[name] - if (!def) return '' - const baseSource = def.base ? resolve(def.base) : '' - return (resolved[name] = baseSource ? `${baseSource}\n${def.source}` : def.source) + if (!def) return { source: '' } + const baseDef = def.base ? resolve(def.base) : null + const source = baseDef ? `${baseDef.source}\n${def.source}` : def.source + const csvs = def.csvs ?? baseDef?.csvs + return (resolved[name] = { source, csvs }) } Object.keys(raw).forEach(resolve) return resolved @@ -69,11 +110,16 @@ export function install(md: MarkdownRenderer) { // strips "[...]" from token.info as a side effect of rendering (for code-group tab // titles), so scanning tokens lazily would miss brackets on already-rendered fences. if (!(env as any)._modelMap) { - (env as any)._modelMap = buildModelMap(tokens) + const dataMap = buildDataMap(tokens) + ;(env as any)._modelMap = buildModelMap(tokens, dataMap) } const { info } = tokens[idx] const [language, live, ...rest] = info.split(' ') + + // Suppress named CSV data blocks marked hidden — content is captured in the pre-pass and shown as a model tab. + if (language === 'csv' && live === 'hidden' && /\[[^\]]+:[^\]]+\]/.test(info)) return '' + if (live === 'live') { const mdDir = dirname(env.realPath ?? env.path) const filePath = './' + relative(mdDir, join(__dirname, '../../theme/components/cds-playground/LiveCode.vue')) @@ -87,15 +133,13 @@ export function install(md: MarkdownRenderer) { const modelArg = rest.find((p: string) => MODEL_ARG_RE.test(p)) const modelName = modelArg ? modelArg.slice(1, -1) : null - let modelSource = '' - if (modelName) { - modelSource = (env as any)._modelMap[modelName] ?? '' - } + const modelDef: ModelDef | undefined = modelName ? (env as any)._modelMap[modelName] : undefined const props: Record = { language: opts.as ?? language, } - if (modelSource) props.modelSource = md.utils.escapeHtml(modelSource) + if (modelDef?.source) props.modelSource = md.utils.escapeHtml(modelDef.source) + if (modelDef?.csvs) props.modelData = md.utils.escapeHtml(JSON.stringify(modelDef.csvs)) const flags = ['readonly'].filter(k => rest.includes(k)) diff --git a/.vitepress/theme/components/cds-playground/LiveCode.vue b/.vitepress/theme/components/cds-playground/LiveCode.vue index a39cc0c773..479897be88 100644 --- a/.vitepress/theme/components/cds-playground/LiveCode.vue +++ b/.vitepress/theme/components/cds-playground/LiveCode.vue @@ -30,7 +30,7 @@
{{ props.language === 'cds'? 'cql' : props.language }} - +
@@ -14,6 +14,7 @@ @@ -77,6 +78,7 @@ import play from '/icons/play.svg?url&raw' import { runners, runWithModel } from './runners' import highlighter from './highlighter' import templates from 'virtual:templates' +import { transformerMetaHighlight } from '@shikijs/transformers' const uid = useId() @@ -91,6 +93,10 @@ const props = defineProps({ type: Boolean, default: false }, + async: { + type: Boolean, + default: false + }, language: { type: String, default: 'js' @@ -103,6 +109,10 @@ const props = defineProps({ type: String, default: '' }, + highlightLines: { + type: String, + default: '' + }, onEvaluate: { type: Function } @@ -156,14 +166,18 @@ function toggleModel() { } } -function format({ value, kind }, dark) { +function format({ value, kind }, dark, highlightSpec = '') { if (!highlighter.getLoadedLanguages().includes(kind)) { kind = 'plaintext' } - const html = highlighter.codeToHtml( + const opts = { lang: kind, theme: dark ? 'github-dark' : 'github-light', transformers: [], meta: undefined } + if (highlightSpec) { + opts.meta = { __raw: highlightSpec } + opts.transformers = [transformerMetaHighlight()] + } + return highlighter.codeToHtml( typeof value === 'string' ? value : JSON.stringify(value, null, 2), - { lang: kind, theme: dark ? 'github-dark' : 'github-light' }) - return html + opts) } function formatTabs(result) { @@ -220,7 +234,7 @@ async function evaluate() { const exec = props.onEvaluate ?? (props.modelSource ? (q) => runWithModel(q, props.modelSource, props.modelData ? JSON.parse(props.modelData) : undefined) : runners[props.language]) if (!exec) throw new Error(`No runner found for language: ${props.language}. Available runners: ${Object.keys(runners).join(', ')}`) - const result = await exec(queryText.value) + const result = await exec(queryText.value, props.async) tabs.value = formatTabs(result).filter(({ value }) => value) if (!tabs.value.map(tab => tab.key).includes(selectedTab.value)) selectedTab.value = tabs.value[0].key diff --git a/.vitepress/theme/components/cds-playground/MonacoEditor.vue b/.vitepress/theme/components/cds-playground/MonacoEditor.vue index 98d5a65776..6d16244bd4 100644 --- a/.vitepress/theme/components/cds-playground/MonacoEditor.vue +++ b/.vitepress/theme/components/cds-playground/MonacoEditor.vue @@ -26,6 +26,10 @@ const props = defineProps({ rows: { type: Number, default: 3 + }, + highlightLines: { + type: String, + default: '' } }) @@ -86,6 +90,17 @@ async function createEditor() { try { const contentSizeDispose = editor.onDidContentSizeChange(() => updateHeight()) updateHeight() + if (props.highlightLines) { + const lines = props.highlightLines.replace(/^\{|\}$/g, '').split(',').flatMap(part => { + const [a, b] = part.trim().split('-').map(Number) + return b ? Array.from({ length: b - a + 1 }, (_, i) => a + i) : [a] + }) + editor.createDecorationsCollection(lines.map(line => ({ + range: new monaco.Range(line, 1, line, 1), + options: { isWholeLine: true, className: 'live-code-highlighted-line' } + }))) + } + // Emit evaluate on Cmd/Ctrl+Enter editor.addAction({ id: 'eval', @@ -155,4 +170,8 @@ watch(() => isDark.value, (dark) => { background-color: var(--vp-code-block-bg) !important; font-family: var(--vp-font-family-mono) !important; } + +.live-code-highlighted-line { + background-color: var(--vp-code-line-highlight-color) !important; +} diff --git a/.vitepress/theme/components/cds-playground/highlighter.js b/.vitepress/theme/components/cds-playground/highlighter.js index 5dce6261c4..381d52e43d 100644 --- a/.vitepress/theme/components/cds-playground/highlighter.js +++ b/.vitepress/theme/components/cds-playground/highlighter.js @@ -3,7 +3,7 @@ import languages from '../../../languages' const highlighter = await createHighlighter({ themes: ['github-dark', 'github-light'], - langs: ['javascript', 'js', 'sql', 'typescript', 'vue', ...languages], + langs: ['javascript', 'js', 'sql', 'typescript', 'vue', 'yaml', ...languages], langAlias: Object.fromEntries(languages.flatMap(l => { if (!l || typeof l !== 'object' || !Array.isArray(l.aliases) || !l.name) return [] return l.aliases.map(alias => [alias, l.name]) diff --git a/.vitepress/theme/components/cds-playground/runners.js b/.vitepress/theme/components/cds-playground/runners.js index c383391cc3..110f4d843e 100644 --- a/.vitepress/theme/components/cds-playground/runners.js +++ b/.vitepress/theme/components/cds-playground/runners.js @@ -27,7 +27,7 @@ function injectLogger(sqlite) { return sqlLog; } - +/** @returns {Promise} */ async function initialize() { const cds = (await import('@sap/cds')).default; const express = (await import('express')).default; @@ -61,6 +61,7 @@ async function initialize() { return cds; } +/** @type {ReturnType} */ let initialized; if (!import.meta.env.SSR) { // runs only in the browser @@ -68,15 +69,40 @@ if (!import.meta.env.SSR) { } const AsyncFunction = async function () {}.constructor; -async function evalJS(code) { - await initialized; - const fn = new AsyncFunction(code); - const { result, formatted } = await sql.trace(fn); +async function evalJS(code, isAsync) { + const cds = await initialized; + const source = compile(code); + + function resultTabs(result, kind) { + if (kind === 'json') { + let yaml + try { yaml = cds.compile.to.yaml(result) } catch {} + if (yaml) return [ + { value: yaml, kind: 'yaml', name: 'Result (as yaml)' }, + { value: JSON.stringify(result, null, 2), kind: 'json', name: 'Result (raw)' }, + ] + } + return [{ value: result ? typeof result !== 'string' ? JSON.stringify(result, null, 2) : result : "success", kind, name: 'Result' }] + } + + if (isAsync) { + let fn; + try { fn = new AsyncFunction(source) } + catch (e) { fn = new AsyncFunction(code) } // rewrite had a syntax error -> run the code unmodified + const { result, formatted } = await sql.trace(fn); + const kind = result? 'json' : 'plaintext' + return [ + ...resultTabs(result, kind), + { value: formatted, kind: 'sql', name: 'SQL'} + ]; + } + + let fn; + try { fn = new Function(source) } + catch (e) { fn = new Function(code) } // rewrite had a syntax error -> run the code unmodified + const result = fn(); const kind = result? 'json' : 'plaintext' - return [ - { value: result ? typeof result !== 'string' ? JSON.stringify(result, null, 2) : result : "success", kind, name: 'Result' }, - { value: formatted, kind: 'sql', name: 'SQL'} - ]; + return resultTabs(result, kind); } async function cdsQL(query) { @@ -138,3 +164,103 @@ export const runners = { cql: cdsQL, cds: cdsQL, } + +function compile(code) { + const stmts = splitTopLevelStatements(code) + if (!stmts.length) return code + const last = stmts[stmts.length - 1] + + // last statement already returns, or is a control-flow/declaration keyword -> leave the code as is + if (/^(return|throw|if|for|while|function|class|import|export)\b/.test(last.text)) return code + + // anchored right after the keyword so we don't match "=" occurring inside the initializer, e.g. in a template literal + const declRe = /^(?:let|const|var)\s+([A-Za-z_$][\w$]*)\s*=/ + if (declRe.test(last.text)) { + // last statement declares a variable, e.g. "let result = 1+1" -> collect all top-level declarations in the + // snippet so earlier ones aren't silently dropped, e.g. comparing "let q = ...; let p = ..." side by side + const names = stmts.map(s => s.text.match(declRe)?.[1]).filter(Boolean) + return names.length > 1 + ? `${code}\nreturn { ${names.join(', ')} };` + : `${code}\nreturn ${names[0]};` + } + + // last statement isn't a declaration -> treat it (possibly spanning multiple lines) as the expression to return + return `${code.slice(0, last.start)}\nreturn (\n${last.text.replace(/;\s*$/, '')}\n);` +} + + +// splits code into its top-level statements (ignoring newlines/semicolons nested inside brackets, strings, +// template literals or comments), so multi-line statements like object literals are kept intact as one unit +function splitTopLevelStatements(code) { + const scrubbed = blankComments(code) // same length as code, but with comments replaced by spaces + const stmts = [] + let start = 0, depth = 0, i = 0 + while (i < scrubbed.length) { + const c = scrubbed[i] + if (c === '"' || c === "'") { i = skipString(scrubbed, i, c); continue } + if (c === '`') { i = skipTemplate(scrubbed, i); continue } + if (c === '(' || c === '{' || c === '[') { depth++; i++; continue } + if (c === ')' || c === '}' || c === ']') { depth--; i++; continue } + if (depth <= 0 && (c === ';' || c === '\n')) { + const text = scrubbed.slice(start, i).trim() + if (text) stmts.push({ text, start }) + i++; start = i; continue + } + i++ + } + const text = scrubbed.slice(start).trim() + if (text) stmts.push({ text, start }) + return stmts +} + +// replaces line and block comments with spaces of the same length, so a trailing comment (e.g. after the last +// statement, or commented-out code on its own line) is never mistaken for code, while offsets stay unchanged +function blankComments(code) { + let out = '' + let i = 0 + while (i < code.length) { + const c = code[i] + if (c === '/' && code[i + 1] === '/') { while (i < code.length && code[i] !== '\n') { out += ' '; i++ }; continue } + if (c === '/' && code[i + 1] === '*') { + while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) { out += code[i] === '\n' ? '\n' : ' '; i++ } + out += ' '; i += 2; continue + } + if (c === '"' || c === "'") { const j = skipString(code, i, c); out += code.slice(i, j); i = j; continue } + if (c === '`') { const j = skipTemplate(code, i); out += code.slice(i, j); i = j; continue } + out += c; i++ + } + return out +} + +// skips a single- or double-quoted string starting at code[i], returning the index right after the closing quote +function skipString(code, i, quote) { + i++ + while (i < code.length && code[i] !== quote) { if (code[i] === '\\') i++; i++ } + return i + 1 +} + +// skips a template literal starting at code[i] (the opening backtick), diving into ${...} interpolations +function skipTemplate(code, i) { + i++ + while (i < code.length) { + if (code[i] === '\\') { i += 2; continue } + if (code[i] === '`') return i + 1 + if (code[i] === '$' && code[i + 1] === '{') { i = skipBraces(code, i + 2); continue } + i++ + } + return i +} + +// skips forward to the '}' balancing the '${' whose contents start at code[i] +function skipBraces(code, i) { + let depth = 1 + while (i < code.length && depth > 0) { + const c = code[i] + if (c === '"' || c === "'") { i = skipString(code, i, c); continue } + else if (c === '`') { i = skipTemplate(code, i); continue } + else if (c === '{') depth++ + else if (c === '}') depth-- + i++ + } + return i +} diff --git a/cds/cdl.md b/cds/cdl.md index 9487f78260..e4ffc07e43 100644 --- a/cds/cdl.md +++ b/cds/cdl.md @@ -147,8 +147,7 @@ Within those strings, escape sequences from JavaScript, such as `\t` or `\u0020` Using directives allow to import definitions from other CDS models. As shown in line 3 below, you optionally can specify local aliases to be used subsequently. You can import single definitions as well as several ones with a common namespace prefix. -::: code-group - +```cds using foo.bar.scoped.Bar from './contexts'; using foo.bar.scoped.nested from './contexts'; using foo.bar.scoped.nested as animal from './contexts'; @@ -158,8 +157,6 @@ entity Moo : nested.Zoo {} //> : foo.bar.scoped.nested.Zoo entity Zoo : animal.Zoo {} //> : foo.bar.scoped.nested.Zoo ``` -::: - Multiple named imports through ES6-like deconstructors: ```cds diff --git a/cds/cxl.md b/cds/cxl.md index 41936fe71e..9c72081040 100644 --- a/cds/cxl.md +++ b/cds/cxl.md @@ -44,7 +44,7 @@ The cds model initialized on this page is a slightly modified version of the [ca All samples run on a single browser-local `cds` instance, you can access it via the dev tools or run statements in the following code block: -```js live +```js live [async] await INSERT.into('Books').entries( { ID: 2, author_ID: 150, title: 'Eldorado' } ) diff --git a/node.js/cds-compile.md b/node.js/cds-compile.md index 7c2ae8f405..e493580e39 100644 --- a/node.js/cds-compile.md +++ b/node.js/cds-compile.md @@ -54,7 +54,7 @@ let csn = await cds.compile ('file:db') > The given filenames are resolved to effective absolute filenames using [`cds.resolve`](#cds-resolve). > [!TIP] Use cds compile as CLI equivalent -> The [`cds compile` CLI](../tools/cds-cli#cds-compile) is available as entry point to the functions described here. For example, `cds compile --to hana` maps to `cds.compile.to.hana` etc. +> The [`cds compile` CLI](../tools/cds-cli#cds-compile) is available as entry point to the functions described here. For example, `cds compile --to hana` maps to [`cds.compile.to.hana`](#hana) etc. @@ -62,34 +62,35 @@ let csn = await cds.compile ('file:db') If a single string, not starting with `file:` is passed as first argument, it is interpreted as a CDL source string and compiled to CSN synchronously: -```js +```js live let csn = cds.compile (` - using {cuid} from '@sap/cds/common'; - entity Foo : cuid { foo:String } + entity Foo { foo:String } entity Bar as projection on Foo; extend Foo with { bar:String } `) ``` -> Note: `using from` clauses are not resolved in this usage. - +> [!note] `using from` clauses are not resolved in this usage. +> In this example, there is an error at the line where [`cuid`](../cds/common#aspect-cuid) gets used: +> +> ```js live +> let csn = cds.compile (` +> using { cuid } from '@sap/cds/common'; +> entity Foo : cuid { foo:String } +> `) +> ``` ### Multiple in-memory sources -Finally, you can pass an object with multiple named CDL or CSN sources, which allows to also resolve `using from` clauses: +Finally, you can pass an object with multiple named CDL or CSN sources, which allows to also resolve [`using from` clauses](../cds/cdl#model-imports): -```js +```js live {3,6} let csn = cds.compile ({ 'db/schema.cds': ` - using {cuid} from '@sap/cds/common'; + using { cuid } from '@sap/cds/common'; entity Foo : cuid { foo:String } `, - 'srv/services.cds': ` - using {Foo} from '../db/schema'; - entity Bar as projection on Foo; - extend Foo with { bar:String } - `, '@sap/cds/common.csn': ` {"definitions":{ "cuid": { "kind": "aspect", "elements": { @@ -100,20 +101,30 @@ let csn = cds.compile ({ }) ``` - - +> [!tip] Reference imported models with canonic names +> In the example, note that the `@sap/cds/common.csn` source is referenced through the canonic `@sap/cds/common` name. +> From the usage perspective, it should not matter if imported definitions are defined as [CDL](../cds/cdl) or [CSN](../cds/csn) and what their technical (file) name is. +> +> [Learn more on CDS model resolution.](../cds/cdl#model-resolution){.learn-more} ### Additional Options You can pass additional options like so: -```js -let csn = await cds.compile('*',{ min:true, docs:true }) +```js live +let messages = [] +let csn = cds.compile(` + /** A comment about */ + /** entity Foo */ + entity Foo { foo:String } + entity Bar as projection on Foo; + type T { e:String } + `, + { min:true, flavor:'parsed', docs:true, locations:true, messages } +) ``` - - | Option | Description | | ----------- | ------------------------------------------------------------ | | `flavor` | By default the returned CSN is in `'inferred'` flavor, which is an effective model, with all aspects, includes, extensions and redirects applied and all views and projections inferred. Specify `'parsed'` to only have single models parsed. | @@ -122,15 +133,22 @@ let csn = await cds.compile('*',{ min:true, docs:true }) | `locations` | Specify `true` to have the all `$location` properties preserved in serialized CSN. | | `messages` | Pass an empty array to get all compiler messages collected in there. | - +Run the example. See that: +- `messages` is filled with a compiler warning about the double comment, +- `docs:true` leads to a `doc` field in the CSN for entity `Foo`, +- `min:true` discards the unused type `T`, +- `flavor:'parsed'` does not resolve `Bar`'s elements from `Foo`. ## cds. compile .to ... {.property} Following are a collection of model processors which take a CSN as input and compile it to a target output. They can be used in two API flavors: -```js +```js live +let csn = {definitions:{}} let sql = cds.compile(csn).to.sql ({dialect:'sqlite'}) //> fluent +``` +```js live let sql = cds.compile.to.sql (csn,{dialect:'sqlite'}) //> direct ``` @@ -189,6 +207,7 @@ for (let [edm,{file,suffix}] of all) Use [`cds.compile.to.hana`](#hana) instead. ### .hana() {.method} +###### hana Generates `hdbtable/hdbview` output. diff --git a/node.js/cds-ql.md b/node.js/cds-ql.md index 67d92763e1..05896fce9e 100644 --- a/node.js/cds-ql.md +++ b/node.js/cds-ql.md @@ -18,29 +18,28 @@ Module `cds.ql` provides facilities to construct queries in [*Core Query Notatio 1. Fluent API style, with query-by-example objects for where clauses and order by clauses: -```js +```js live let q = SELECT.from('Books').where({ID:201}).orderBy({title:1}) ``` 2. Using with [tagged template literals (TTL)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates): -```js +```js live let q = cds.ql `SELECT from Books where ID=${201} order by title` ``` 3. Fluent API with interspersed [tagged template literals (TTL)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates): -```js +```js live let q = SELECT.from `Books where ID=${201} order by title` let p = SELECT.from `Books`.where`ID=${201}`.orderBy`title` ``` 4. Manually constructing CQN objects: -```js +```js live const { expr, ref, val, columns, expand, where, orderBy } = cds.ql -``` -```js + let q = { SELECT: { from: ref`Books`, @@ -49,7 +48,9 @@ let q = { } } ``` -```js +```js live +const { expr, ref, val, columns, expand, where, orderBy } = cds.ql + let q = { SELECT: { from: ref`Authors`, @@ -78,7 +79,7 @@ const { SELECT, INSERT, UPDATE, DELETE } = cds.ql It is recommended best practice to use entity definitions reflected from a service's model to construct queries. Doing so simplifies code as it avoids repeating namespaces all over the place. -```js +```js live const { Books } = cds.entities let q1 = SELECT.from (Books) .where `ID=${201}` ``` @@ -96,30 +97,32 @@ While both [CQL](../cds/cql) / [CQN](../cds/cqn) as well as the fluent API of `c Queries are executed by passing them to a service's [`srv.run()`](core-services#srv-run-query) method, for example, to the primary database: -```js +```js live [async] let query = SELECT `ID,title` .from `Books` let books = await cds.db.run (query) ``` Alternatively, you can just `await` a constructed query, which by default passes the query to `cds.db.run()`. So, the following is equivalent to the above: -```js +```js live [async] let books = await SELECT `ID,title` .from `Books` ``` Instead of a database service, you can also send queries to other services, local or remote ones. For example: -```js +```js live [async] const cats = await cds.connect.to ('CatalogService') +let query = SELECT `ID,title` .from `Books` let books = await cats.run (query) +return {query, books} ``` > `CatalogService` might be a remote service connected via OData. In this case, the query would be translated to an OData request sent via HTTP. The APIs are also available through [`cds.Service`'s CRUD-style Convenience API](core-services#crud-style-api), for example: -```js +```js live [async] const db = cds.db -let books = await db.read`Books`.where`ID=${201}`.orderBy`title` +await db.read`Books`.where`ID=${201}`.orderBy`title` ``` @@ -128,10 +131,10 @@ let books = await db.read`Books`.where`ID=${201}`.orderBy`title` Constructing queries doesn't execute them immediately, but just captures the given query information. Very much like functions in JavaScript, queries are first-class objects, which can be assigned to variables, modified, passed as arguments, or returned from functions. Let's investigate this somewhat more, given this example: -```js -let cats = await cds.connect.to('CatalogService') //> connected via OData -let PoesBooks = SELECT.from (Books) .where `name like '%Poe%'` -let books = await cats.get (PoesBooks) +```js live [async] +cats = await cds.connect.to('CatalogService')//> connected via OData +PoesBooks = SELECT.from ('Books') .where `author like '%Poe%'` +books = await cats.get (PoesBooks) ``` This is what happens behind the scenes: @@ -150,24 +153,26 @@ This is what happens behind the scenes: You can also combine queries much like sub selects in SQL to form more complex queries as shown in this example: -```sql +```js live [async] let input = '%Brontë%' let Authors = SELECT `ID` .from `Authors` .where `name like ${ input }` let Books = SELECT.from `Books` .where `author_ID in ${ Authors }` -``` -```js await cds.run (Books) //> late/no materialization of Authors +// TODO fails with 'Authors not found' ``` With that we leverage late materialization, offered by SQL databases. Compare that to inferior imperative programming: -```js +```js live [async] let input = '%Brontë%' let Authors = await SELECT `ID` .from `Authors` .where `name like ${ input }` -for (let a of Authors) { //> looping over eagerly materialized Authors - let Books = await SELECT.from `Books` .where `author_ID = ${ a.ID }` -} +// looping over eagerly materialized Auxthors +let books = [] +for (let a of Authors) { books.push ( + ...await SELECT.from `Books` .where `author_ID = ${ a.ID }` +)} +return books ``` From 07e7789943bff363143f58d048bbe45fa5700612 Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Wed, 12 Aug 2026 22:43:31 +0200 Subject: [PATCH 13/22] fix lint --- .vitepress/theme/components/cds-playground/runners.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.vitepress/theme/components/cds-playground/runners.js b/.vitepress/theme/components/cds-playground/runners.js index 110f4d843e..93d2413684 100644 --- a/.vitepress/theme/components/cds-playground/runners.js +++ b/.vitepress/theme/components/cds-playground/runners.js @@ -76,7 +76,7 @@ async function evalJS(code, isAsync) { function resultTabs(result, kind) { if (kind === 'json') { let yaml - try { yaml = cds.compile.to.yaml(result) } catch {} + try { yaml = cds.compile.to.yaml(result) } catch {/* ignore */} if (yaml) return [ { value: yaml, kind: 'yaml', name: 'Result (as yaml)' }, { value: JSON.stringify(result, null, 2), kind: 'json', name: 'Result (raw)' }, @@ -88,7 +88,7 @@ async function evalJS(code, isAsync) { if (isAsync) { let fn; try { fn = new AsyncFunction(source) } - catch (e) { fn = new AsyncFunction(code) } // rewrite had a syntax error -> run the code unmodified + catch { fn = new AsyncFunction(code) } // rewrite had a syntax error -> run the code unmodified const { result, formatted } = await sql.trace(fn); const kind = result? 'json' : 'plaintext' return [ @@ -99,7 +99,7 @@ async function evalJS(code, isAsync) { let fn; try { fn = new Function(source) } - catch (e) { fn = new Function(code) } // rewrite had a syntax error -> run the code unmodified + catch { fn = new Function(code) } // rewrite had a syntax error -> run the code unmodified const result = fn(); const kind = result? 'json' : 'plaintext' return resultTabs(result, kind); From 16fbf59f636fdbf73ef2555e784c727e4d92179a Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Thu, 13 Aug 2026 10:56:09 +0200 Subject: [PATCH 14/22] More JS examples --- .vitepress/lib/cds-playground/md-live-code.ts | 9 +- .../components/cds-playground/LiveCode.vue | 15 +++ node.js/cds-compile.md | 126 +++++++++++------- 3 files changed, 98 insertions(+), 52 deletions(-) diff --git a/.vitepress/lib/cds-playground/md-live-code.ts b/.vitepress/lib/cds-playground/md-live-code.ts index 4322f3e0ec..0f104eba1a 100644 --- a/.vitepress/lib/cds-playground/md-live-code.ts +++ b/.vitepress/lib/cds-playground/md-live-code.ts @@ -22,6 +22,8 @@ import { enabled } from '.' * example: ```cds live readonly * - [ModelName]: run query against a named model defined elsewhere on the page * example: ```cds live [FooBar] + * - [result:]: format the result as the given language (e.g. sql) instead of JSON + * example: ```js live [result:sql] * * Named model definitions (static, non-live): * - ```cds [FooBar] — defines a named model; rendered as a plain code block @@ -37,6 +39,7 @@ import { enabled } from '.' */ const MODEL_ARG_RE = /^\[.+\]$/ +const RESULT_KIND_RE = /^\[result:(\w+)\]$/ interface ModelDef { source: string; csvs?: Record } @@ -135,16 +138,20 @@ export function install(md: MarkdownRenderer) { return idx > -1 ? [key, rest.splice(idx+1, 1)[0]] : []; })) - const modelArg = rawRest.find((p: string) => MODEL_ARG_RE.test(p)) + const modelArg = rawRest.find((p: string) => MODEL_ARG_RE.test(p) && !RESULT_KIND_RE.test(p)) const modelName = modelArg ? modelArg.slice(1, -1) : null const modelDef: ModelDef | undefined = modelName ? (env as any)._modelMap[modelName] : undefined + const resultArg = rawRest.find((p: string) => RESULT_KIND_RE.test(p)) + const resultKind = resultArg ? RESULT_KIND_RE.exec(resultArg)![1] : null + const props: Record = { language: opts.as ?? language, } if (modelDef?.source) props.modelSource = md.utils.escapeHtml(modelDef.source) if (modelDef?.csvs) props.modelData = md.utils.escapeHtml(JSON.stringify(modelDef.csvs)) if (highlightSpec) props.highlightLines = highlightSpec + if (resultKind) props.resultKind = resultKind const flags = ['readonly', 'async'].filter(k => rest.includes(k)) diff --git a/.vitepress/theme/components/cds-playground/LiveCode.vue b/.vitepress/theme/components/cds-playground/LiveCode.vue index b49f68bc01..37c6c10507 100644 --- a/.vitepress/theme/components/cds-playground/LiveCode.vue +++ b/.vitepress/theme/components/cds-playground/LiveCode.vue @@ -113,6 +113,10 @@ const props = defineProps({ type: String, default: '' }, + resultKind: { + type: String, + default: '' + }, onEvaluate: { type: Function } @@ -181,6 +185,17 @@ function format({ value, kind }, dark, highlightSpec = '') { } function formatTabs(result) { + if (props.resultKind) { + // evalJS wraps results in tab objects with a possibly JSON-stringified value; unwrap first. + // When resultTabs produces yaml+json tabs, take the json tab (not the yaml one at [0]). + const raw = Array.isArray(result) && result[0]?.kind !== undefined + ? (result.find(t => t.kind === 'json') ?? result[0]).value + : result + let data = raw + if (typeof raw === 'string') try { data = JSON.parse(raw) } catch {} + const value = Array.isArray(data) ? data.join(';\n') : typeof data === 'string' ? data : JSON.stringify(data, null, 2) + return [{ key: `${uid}-Result`, kind: props.resultKind, name: `Result (as ${props.resultKind.toUpperCase()})`, value }] + } if (result && result.kind && result.value) { const { kind, name = 'Result', value } = result return [ diff --git a/node.js/cds-compile.md b/node.js/cds-compile.md index e493580e39..bb01e5cb83 100644 --- a/node.js/cds-compile.md +++ b/node.js/cds-compile.md @@ -144,12 +144,20 @@ Run the example. See that: Following are a collection of model processors which take a CSN as input and compile it to a target output. They can be used in two API flavors: -```js live -let csn = {definitions:{}} -let sql = cds.compile(csn).to.sql ({dialect:'sqlite'}) //> fluent +```js live [result:sql] {5} +let csn = cds.parse(` + entity Foo { foo:String } + entity Bar as projection on Foo; +`) +cds.compile(csn).to.sql ({dialect:'sqlite'}) //> fluent ``` -```js live -let sql = cds.compile.to.sql (csn,{dialect:'sqlite'}) //> direct + +```js live [result:sql] {5} +let csn = cds.parse(` + entity Foo { foo:String } + entity Bar as projection on Foo; +`) +cds.compile.to.sql (csn, {dialect:'sqlite'}) //> direct ``` @@ -190,16 +198,24 @@ Accepted `options` are the same [as documented for `cds.compile`](#additional-op In case of the latter, a generator is returned that yields `[ edm, {file, suffix} ]` for each service. For example, use it as follows: -```js +```js live {8} +let csn = cds.parse(` + entity Foo { key foo:String } + service CatalogService { entity Bar as projection on Foo; } +`) // for one service -let edm = cds.compile.to.edm (csn, {service:'Catalog'}) -console.log (edm) +cds.compile.to.edm (csn, {service:'CatalogService'}) ``` -```js +```js live {7-8} +let csn = cds.parse(` + service CatalogService { entity Foo { key foo:String } } + service AdminService { entity Bar { key bar:String } } +`) +let result = [] // for all services let all = cds.compile.to.edm (csn, {service:'all'}) -for (let [edm,{file,suffix}] of all) - console.log (file,suffix,edm) +for (let [edm,{file,suffix}] of all) result.push ({file,suffix,edm}) +return result ``` ### .hdbtable() {.method .deprecated} @@ -215,11 +231,15 @@ Returns a generator function that produces `[ content, {file} ]` for each artifa For example, use it as follows: -```js +```js live {6-7} +let csn = cds.parse(` + entity Foo { key foo:String } + service CatalogService { entity Bar as projection on Foo; } +`) +let result = [] const all = cds.compile.to.hana(csn); -for (const [content, { file }] of all) { - console.log(file, content); -} +for (const [content, { file }] of all) result.push({file,content}) +return result ``` Additional data for `.hdbmigrationtable` files is calculated if a `beforeImage` parameter is passed in. This is only relevant for build tools to determine the actual migration table changes. @@ -232,24 +252,41 @@ The default returns an array with the generated statements. Accepted `options` are: -- `dialect`: _'plain' \| 'sqlite' \| 'postgres' \| 'h2'_ → chooses the dialect to generate +- `dialect`: _'plain' \| 'sqlite' \| 'postgres' \| 'hana' \| 'h2'_ → chooses the dialect to generate - `names`: _'plain' \| 'quoted'_ → allows to generate DDL using quoted names - `as`: _'str'_ → returns a string with concatenated DDL statements. -Examples: -```js -let ddls1 = cds.compile(csn).to.sql() -let ddls2 = cds.compile(csn).to.sql({dialect:'plain'}) -let script = cds.compile(csn).to.sql({as:'str'}) +#### Examples + +Default mode: +```js live {5} +let csn = cds.parse(` + entity Foo { key foo:String; date:Date } + service CatalogService { entity Bar as projection on Foo; } +`) +cds.compile(csn).to.sql() ``` +Dialect `hana` with quoted names returning a plain string: +```js live [result:sql] {5} +let csn = cds.parse(` + entity Foo { key foo:String; date:Date } + service CatalogService { entity Bar as projection on Foo; } +`) +cds.compile(csn).to.sql({dialect:'hana', names:'quoted', as:'str'}) +``` ### .cdl() {.method} Reconstructs [CDL](../cds/cdl.md) source code for the given csn model. - +```js live [result:cds] {6} +let csn = { definitions: { + Foo: { kind: 'entity', elements: { foo: { type: 'cds.String' }}} +}} +cds.compile(csn).to.cdl() +``` ### .asyncapi() {.method} @@ -257,12 +294,10 @@ Reconstructs [CDL](../cds/cdl.md) source code for the given csn model. Convert the CSN file into an AsyncAPI document: ```js -const doc = cds.compile.to.asyncapi(csn_file) +const doc = cds.compile.to.asyncapi(csn) ``` - - - +The `asyncapi` function is only available through the [designtime package `@sap/cds-dk`](../tools/apis/). @@ -311,10 +346,10 @@ Parses a source string in _[CDL](../cds/cdl)_ syntax and returns it as a parsed It's essentially a [shortcut to `cds.compile (..., {flavor:'parsed'})`](#cds-compile-). Examples: -```js -let csn = cds.parse.cdl (`entity Foo{}`) -let csn = cds.parse.cdl `entity Foo{}` -let csn = cds.parse `entity Foo{}` //> shortcut to the above +```js live +let csn1 = cds.parse.cdl (`entity Foo{}`) +let csn2 = cds.parse.cdl `entity Foo{}` +let csn3 = cds.parse `entity Foo{}` //> shortcut to the above ``` @@ -324,9 +359,9 @@ let csn = cds.parse `entity Foo{}` //> shortcut to the above Parses a source string in _[CQL](../cds/cql)_ syntax and returns it as a parsed query according to the [_CQN spec_](../cds/cqn). Supports tagged template strings as well as plain string arguments. Examples: -```js -let cqn = cds.parse.cql (`SELECT * from Foo`) -let cqn = cds.parse.cql `SELECT * from Foo` +```js live +let cqn1 = cds.parse.cql (`SELECT * from Foo`) +let cqn2 = cds.parse.cql `SELECT * from Foo` ``` @@ -336,24 +371,19 @@ let cqn = cds.parse.cql `SELECT * from Foo` Parses a source string in CQL expression syntax and returns it as a parsed expression according to the [_CQN Expressions spec_](../cds/cxn#operators). Supports tagged template strings as well as plain string arguments. Examples: -```js -[dev] cds repl -> let cxn = cds.parse.expr (`foo.bar > 9`) -> let cxn = cds.parse.expr `foo.bar > 9` //> both return: -{xpr:[ {ref:['foo', 'bar']}, '>', {val:9} ] } +```js live +let cxn1 = cds.parse.expr (`foo.bar > 9`) +let cxn2 = cds.parse.expr `foo.bar > 9` ``` - ### cds. parse. xpr() {.method} Convenience shortcut to `cds.parse.expr(x).xpr` Example: -```js -[dev] cds repl -> let xpr = cds.parse.xpr (`foo.bar > 9`) // [!code focus] -[ {ref:['foo', 'bar']}, '>', {val:9} ] +```js live +let xpr = cds.parse.xpr (`foo.bar > 9`) ``` @@ -363,18 +393,12 @@ Example: Convenience shortcut to `cds.parse.expr(x).ref` Example: -```js -[dev] cds repl -> let ref = cds.parse.ref (`foo.bar`) // [!code focus] -['foo', 'bar'] +```js live +let ref = cds.parse.ref (`foo.bar`) ``` - - - - ## cds. minify() {.method} Minifies a given CSN model by removing all unused1 types and aspects, as well all entities tagged with `@cds.persistence.skip:'if-unused'`. Use it like that: From 20161f47dbaacbbafaa847e6501953097ed7c3e7 Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Mon, 17 Aug 2026 15:40:49 +0200 Subject: [PATCH 15/22] Simplify md syntax to just key=value pairs --- .vitepress/lib/cds-playground/md-live-code.ts | 154 ++++++++---------- cds/cql.md | 16 +- cds/cxl.md | 2 +- node.js/cds-compile.md | 8 +- node.js/cds-ql.md | 14 +- 5 files changed, 88 insertions(+), 106 deletions(-) diff --git a/.vitepress/lib/cds-playground/md-live-code.ts b/.vitepress/lib/cds-playground/md-live-code.ts index 0f104eba1a..d8475cdf3c 100644 --- a/.vitepress/lib/cds-playground/md-live-code.ts +++ b/.vitepress/lib/cds-playground/md-live-code.ts @@ -15,61 +15,56 @@ import { enabled } from '.' * ) * ``` * - * Additional options: - * - as : specify the language to execute the code block as (defaults to the language specified before "live") - * example: ```cds live as cql + * Options use key=value pairs; boolean flags are standalone words: + * - model=: run query against a named model defined elsewhere on the page + * example: ```cds live model=FooBar + * - result=: format the result as the given language (e.g. sql) instead of JSON + * example: ```js live result=sql + * - as=: execute the code block as a different language + * example: ```cds live as=cql * - readonly: make the code block readonly * example: ```cds live readonly - * - [ModelName]: run query against a named model defined elsewhere on the page - * example: ```cds live [FooBar] - * - [result:]: format the result as the given language (e.g. sql) instead of JSON - * example: ```js live [result:sql] + * - async: run the query asynchronously + * example: ```js live async * * Named model definitions (static, non-live): - * - ```cds [FooBar] — defines a named model; rendered as a plain code block - * - ```cds [FooBarBoo: FooBar] — extends FooBar; combined source is resolved at render time - * - ```cds [FooBar, data: FooData] — attaches a named CSV data set to the model + * - ```cds model=FooBar — defines a named model; rendered as a plain code block + * - ```cds model=FooBarBoo:FooBar — extends FooBar; combined source is resolved at render time + * - ```cds model=FooBar data=FooData — attaches a named CSV data set to the model * * Named CSV data sets (static, non-live): - * - ```csv [FooData: data/Foo.csv] — defines a named data set; rendered as a plain code block - * - ```csv hidden [FooData: data/Foo.csv] — same, but suppressed from output (not rendered) + * - ```csv data=FooData:db/Foo.csv — defines a named data set; rendered as a plain code block + * - ```csv hidden data=FooData:db/Foo.csv — same, but suppressed from output (not rendered) * * CSV and model blocks may appear anywhere on the page — they are collected in a full token pass * before any fence is rendered, so forward references work. */ -const MODEL_ARG_RE = /^\[.+\]$/ -const RESULT_KIND_RE = /^\[result:(\w+)\]$/ - interface ModelDef { source: string; csvs?: Record } -function parseBracketKV(inner: string): { name: string; base?: string; data?: string } { - const commaIdx = inner.indexOf(',') - const namePart = commaIdx === -1 ? inner.trim() : inner.slice(0, commaIdx).trim() - const colonIdx = namePart.indexOf(':') - const name = colonIdx === -1 ? namePart : namePart.slice(0, colonIdx).trim() - const base = colonIdx === -1 ? undefined : namePart.slice(colonIdx + 1).trim() - let data: string | undefined - if (commaIdx !== -1) { - const dataMatch = inner.slice(commaIdx + 1).match(/\bdata\s*:\s*(\S+)/) - if (dataMatch) data = dataMatch[1] +function parseInfoKV(parts: string[]): { flags: Set; kv: Record } { + const flags = new Set() + const kv: Record = {} + for (const part of parts) { + const eq = part.indexOf('=') + if (eq === -1) flags.add(part) + else kv[part.slice(0, eq)] = part.slice(eq + 1) } - return { name, base, data } + return { flags, kv } } function buildDataMap(tokens: any[]): Record> { const result: Record> = {} for (const token of tokens) { if (token.type !== 'fence') continue - const bracketMatch = token.info.match(/\[([^\]]+)\]/) - if (!bracketMatch) continue - const [lang] = token.info.slice(0, bracketMatch.index).trim().split(/\s+/) - if (lang !== 'csv') continue - const inner = bracketMatch[1] - const colonIdx = inner.indexOf(':') + const parts = token.info.trim().split(/\s+/) + if (parts[0] !== 'csv') continue + const { kv } = parseInfoKV(parts.slice(1)) + if (!kv.data) continue + const colonIdx = kv.data.indexOf(':') if (colonIdx === -1) continue - const name = inner.slice(0, colonIdx).trim() - const path = inner.slice(colonIdx + 1).trim() + const name = kv.data.slice(0, colonIdx) + const path = kv.data.slice(colonIdx + 1) result[name] = { [path]: token.content.trim() } } return result @@ -79,17 +74,14 @@ function buildModelMap(tokens: any[], dataMap: Record }> = {} for (const token of tokens) { if (token.type !== 'fence') continue - // Match the bracket first since its content may contain spaces (e.g. "[Foo: Bar]"), - // which would otherwise be broken apart by a naive split(' '). - const bracketMatch = token.info.match(/\[([^\]]+)\]/) - if (!bracketMatch) continue - const before = token.info.slice(0, bracketMatch.index).trim().split(/\s+/) - const [lang] = before - if (lang !== 'cds') continue - // Only pick up non-live model definition blocks - if (before.includes('live')) continue - const { name, base, data } = parseBracketKV(bracketMatch[1]) - raw[name] = { source: token.content.trim(), base, csvs: data ? dataMap[data] : undefined } + const parts = token.info.trim().split(/\s+/) + if (parts[0] !== 'cds') continue + const { flags, kv } = parseInfoKV(parts.slice(1)) + if (flags.has('live') || !kv.model) continue + const colonIdx = kv.model.indexOf(':') + const name = colonIdx === -1 ? kv.model : kv.model.slice(0, colonIdx) + const base = colonIdx === -1 ? undefined : kv.model.slice(colonIdx + 1) + raw[name] = { source: token.content.trim(), base, csvs: kv.data ? dataMap[kv.data] : undefined } } const resolved: Record = {} function resolve(name: string): ModelDef { @@ -109,9 +101,6 @@ export function install(md: MarkdownRenderer) { if (!enabled) return const fence = md.renderer.rules.fence md.renderer.rules.fence = (tokens, idx, options, env: MarkdownEnv, ...args) => { - // Build the model map before any fence is rendered: VitePress's preWrapperPlugin - // strips "[...]" from token.info as a side effect of rendering (for code-group tab - // titles), so scanning tokens lazily would miss brackets on already-rendered fences. if (!(env as any)._modelMap) { const dataMap = buildDataMap(tokens) ;(env as any)._modelMap = buildModelMap(tokens, dataMap) @@ -120,45 +109,38 @@ export function install(md: MarkdownRenderer) { const { info } = tokens[idx] const hlMatch = info.match(/\{[\d,\-]+\}/) const highlightSpec = hlMatch?.[0] ?? '' - const infoNormalized = info.replace(/\s*\{[\d,\-]+\}/, '') - const [language, live, ...rawRest] = infoNormalized.split(' ') - - // Suppress named CSV data blocks marked hidden — content is captured in the pre-pass and shown as a model tab. - if (language === 'csv' && live === 'hidden' && /\[[^\]]+:[^\]]+\]/.test(info)) return '' - - const rest = rawRest.map(flag => flag.replace(/^\[|\]$/g, '')) // e.g. "[async]" -> "async" - if (live === 'live') { - const mdDir = dirname(env.realPath ?? env.path) - const filePath = './' + relative(mdDir, join(__dirname, '../../theme/components/cds-playground/LiveCode.vue')) - const imp = `import LiveCode from "${filePath}";` - insertScriptSetup(env, imp) - - const opts = Object.fromEntries(['as'].map(key => { - const idx = rest.findIndex(k => k === key) - return idx > -1 ? [key, rest.splice(idx+1, 1)[0]] : []; - })) - - const modelArg = rawRest.find((p: string) => MODEL_ARG_RE.test(p) && !RESULT_KIND_RE.test(p)) - const modelName = modelArg ? modelArg.slice(1, -1) : null - const modelDef: ModelDef | undefined = modelName ? (env as any)._modelMap[modelName] : undefined - - const resultArg = rawRest.find((p: string) => RESULT_KIND_RE.test(p)) - const resultKind = resultArg ? RESULT_KIND_RE.exec(resultArg)![1] : null - - const props: Record = { - language: opts.as ?? language, - } - if (modelDef?.source) props.modelSource = md.utils.escapeHtml(modelDef.source) - if (modelDef?.csvs) props.modelData = md.utils.escapeHtml(JSON.stringify(modelDef.csvs)) - if (highlightSpec) props.highlightLines = highlightSpec - if (resultKind) props.resultKind = resultKind - - const flags = ['readonly', 'async'].filter(k => rest.includes(k)) - - const content = tokens[idx].content.trim() - return ` `${k}="${v}"`).join(' ')} ${flags.join(' ')}>` + const infoNormalized = info.replace(/\s*\{[\d,\-]+\}/, '').trim() + const parts = infoNormalized.split(/\s+/).filter(Boolean) + const [language = ''] = parts + const { flags, kv } = parseInfoKV(parts.slice(1)) + + // Suppress hidden CSV data blocks — content is captured in the pre-pass + if (language === 'csv' && flags.has('hidden') && kv.data) return '' + + if (!flags.has('live')) { + return fence!(tokens, idx, options, env, ...args) } - return fence!(tokens, idx, options, env, ...args) + + const mdDir = dirname(env.realPath ?? env.path) + const filePath = './' + relative(mdDir, join(__dirname, '../../theme/components/cds-playground/LiveCode.vue')) + const imp = `import LiveCode from "${filePath}";` + insertScriptSetup(env, imp) + + const modelName = kv.model ?? null + const modelDef: ModelDef | undefined = modelName ? (env as any)._modelMap[modelName] : undefined + + const props: Record = { + language: kv.as ?? language, + } + if (modelDef?.source) props.modelSource = md.utils.escapeHtml(modelDef.source) + if (modelDef?.csvs) props.modelData = md.utils.escapeHtml(JSON.stringify(modelDef.csvs)) + if (highlightSpec) props.highlightLines = highlightSpec + if (kv.result) props.resultKind = kv.result + + const liveFlags = ['readonly', 'async'].filter(k => flags.has(k)) + + const content = tokens[idx].content.trim() + return ` `${k}="${v}"`).join(' ')} ${liveFlags.join(' ')}>` } } diff --git a/cds/cql.md b/cds/cql.md index 318a955fd6..7d9945e96a 100644 --- a/cds/cql.md +++ b/cds/cql.md @@ -190,43 +190,43 @@ SELECT from Books { * } excluding { author } The effect is about **late materialization** of signatures and staying open to late extensions. For example, assume the following definitions: -```cds [FooBar, data: FooBarData] +```cds model=FooBar data=FooBarData entity Foo { foo : String; bar : String; car : String; } entity Bar as select from Foo excluding { bar }; entity Boo as select from Foo { foo, car }; ``` -```csv hidden [FooBarData: data/Foo.csv] +```csv hidden data=FooBarData:data/Foo.csv foo,bar,car foo1,bar1,car1 ``` A `SELECT * from Bar` would result into the same as a query of `Boo`: -```cds live [FooBar] +```cds live model=FooBar SELECT * from Bar //> { foo, car } ``` -```cds live [FooBar] +```cds live model=FooBar SELECT * from Boo //> { foo, car } ``` Now, assume a consumer of that package extends the definitions as follows: -```cds [FooBarBoo: FooBar, data: FooBarBooData] +```cds model=FooBarBoo:FooBar data=FooBarBooData extend Foo with { boo : String; } ``` -```csv hidden [FooBarBooData: data/Foo.csv] +```csv hidden data=FooBarBooData:data/Foo.csv foo,bar,car,boo foo1,bar1,car1,boo1 ``` With that, queries on `Bar` and `Boo` would return different results: -```cds live [FooBarBoo] +```cds live model=FooBarBoo SELECT * from Bar //> { foo, car, boo } ``` -```cds live [FooBarBoo] +```cds live model=FooBarBoo SELECT * from Boo //> { foo, car } ``` diff --git a/cds/cxl.md b/cds/cxl.md index 9c72081040..487abe50e5 100644 --- a/cds/cxl.md +++ b/cds/cxl.md @@ -44,7 +44,7 @@ The cds model initialized on this page is a slightly modified version of the [ca All samples run on a single browser-local `cds` instance, you can access it via the dev tools or run statements in the following code block: -```js live [async] +```js live async await INSERT.into('Books').entries( { ID: 2, author_ID: 150, title: 'Eldorado' } ) diff --git a/node.js/cds-compile.md b/node.js/cds-compile.md index 5c5baffd56..751f78d1fc 100644 --- a/node.js/cds-compile.md +++ b/node.js/cds-compile.md @@ -144,7 +144,7 @@ Run the example. See that: Following are a collection of model processors which take a CSN as input and compile it to a target output. They can be used in two API flavors: -```js live [result:sql] {5} +```js live result=sql {5} let csn = cds.parse(` entity Foo { foo:String } entity Bar as projection on Foo; @@ -152,7 +152,7 @@ let csn = cds.parse(` cds.compile(csn).to.sql ({dialect:'sqlite'}) //> fluent ``` -```js live [result:sql] {5} +```js live result=sql {5} let csn = cds.parse(` entity Foo { foo:String } entity Bar as projection on Foo; @@ -268,7 +268,7 @@ cds.compile(csn).to.sql() ``` Dialect `hana` with quoted names returning a plain string: -```js live [result:sql] {5} +```js live result=sql {5} let csn = cds.parse(` entity Foo { key foo:String; date:Date } service CatalogService { entity Bar as projection on Foo; } @@ -281,7 +281,7 @@ cds.compile(csn).to.sql({dialect:'hana', names:'quoted', as:'str'}) Reconstructs [CDL](../cds/cdl.md) source code for the given csn model. -```js live [result:cds] {6} +```js live result=cds {6} let csn = { definitions: { Foo: { kind: 'entity', elements: { foo: { type: 'cds.String' }}} }} diff --git a/node.js/cds-ql.md b/node.js/cds-ql.md index 05896fce9e..7c9f7c8d04 100644 --- a/node.js/cds-ql.md +++ b/node.js/cds-ql.md @@ -97,19 +97,19 @@ While both [CQL](../cds/cql) / [CQN](../cds/cqn) as well as the fluent API of `c Queries are executed by passing them to a service's [`srv.run()`](core-services#srv-run-query) method, for example, to the primary database: -```js live [async] +```js live async let query = SELECT `ID,title` .from `Books` let books = await cds.db.run (query) ``` Alternatively, you can just `await` a constructed query, which by default passes the query to `cds.db.run()`. So, the following is equivalent to the above: -```js live [async] +```js live async let books = await SELECT `ID,title` .from `Books` ``` Instead of a database service, you can also send queries to other services, local or remote ones. For example: -```js live [async] +```js live async const cats = await cds.connect.to ('CatalogService') let query = SELECT `ID,title` .from `Books` let books = await cats.run (query) @@ -120,7 +120,7 @@ return {query, books} The APIs are also available through [`cds.Service`'s CRUD-style Convenience API](core-services#crud-style-api), for example: -```js live [async] +```js live async const db = cds.db await db.read`Books`.where`ID=${201}`.orderBy`title` ``` @@ -131,7 +131,7 @@ await db.read`Books`.where`ID=${201}`.orderBy`title` Constructing queries doesn't execute them immediately, but just captures the given query information. Very much like functions in JavaScript, queries are first-class objects, which can be assigned to variables, modified, passed as arguments, or returned from functions. Let's investigate this somewhat more, given this example: -```js live [async] +```js live async cats = await cds.connect.to('CatalogService')//> connected via OData PoesBooks = SELECT.from ('Books') .where `author like '%Poe%'` books = await cats.get (PoesBooks) @@ -153,7 +153,7 @@ This is what happens behind the scenes: You can also combine queries much like sub selects in SQL to form more complex queries as shown in this example: -```js live [async] +```js live async let input = '%Brontë%' let Authors = SELECT `ID` .from `Authors` .where `name like ${ input }` let Books = SELECT.from `Books` .where `author_ID in ${ Authors }` @@ -164,7 +164,7 @@ await cds.run (Books) //> late/no materialization of Authors With that we leverage late materialization, offered by SQL databases. Compare that to inferior imperative programming: -```js live [async] +```js live async let input = '%Brontë%' let Authors = await SELECT `ID` .from `Authors` .where `name like ${ input }` // looping over eagerly materialized Auxthors From e38bf9e552f274a6e62bee9151701145b02b3a62 Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Wed, 19 Aug 2026 16:04:47 +0200 Subject: [PATCH 16/22] Run bookshop model in worker too --- .../components/cds-playground/LiveCode.vue | 2 +- .../components/cds-playground/cds-worker.js | 206 +++++++++++-- .../components/cds-playground/runners.js | 271 +++--------------- cds/cxl.md | 4 +- 4 files changed, 233 insertions(+), 250 deletions(-) diff --git a/.vitepress/theme/components/cds-playground/LiveCode.vue b/.vitepress/theme/components/cds-playground/LiveCode.vue index 37c6c10507..582a4b9d61 100644 --- a/.vitepress/theme/components/cds-playground/LiveCode.vue +++ b/.vitepress/theme/components/cds-playground/LiveCode.vue @@ -247,7 +247,7 @@ async function evaluate() { queryResult.value = null try { const exec = props.onEvaluate - ?? (props.modelSource ? (q) => runWithModel(q, props.modelSource, props.modelData ? JSON.parse(props.modelData) : undefined) : runners[props.language]) + ?? (props.modelSource ? (q) => runWithModel(q, props.modelSource, props.modelData ? JSON.parse(props.modelData) : undefined, props.language, props.async) : runners[props.language]) if (!exec) throw new Error(`No runner found for language: ${props.language}. Available runners: ${Object.keys(runners).join(', ')}`) const result = await exec(queryText.value, props.async) tabs.value = formatTabs(result).filter(({ value }) => value) diff --git a/.vitepress/theme/components/cds-playground/cds-worker.js b/.vitepress/theme/components/cds-playground/cds-worker.js index 759f071485..dcc683e037 100644 --- a/.vitepress/theme/components/cds-playground/cds-worker.js +++ b/.vitepress/theme/components/cds-playground/cds-worker.js @@ -30,41 +30,215 @@ function injectLogger(sqlite) { } } -async function init(modelSource, csvs) { +/** + * Compile + deploy (+ optionally serve) a CDS model into a browser-local cds instance. + * + * @param {object} payload + * @param {string | Record} payload.model single-file source (named models) + * or a path→content map (the multi-file bookshop model) + * @param {Record} [payload.csvs] seed data keyed by path + * @param {string} [payload.namespace] forced onto the compiled CSN (bookshop) + * @param {boolean} [payload.serve] also `cds.serve('all')` so snippets can `cds.connect.to(...)` + */ +async function init({ model, csvs, namespace, serve }) { state.cds = (await import('@sap/cds')).default; const sqlite = (await import('better-sqlite3')).default; - await sqlite.initialized; + await sqlite.initialized; // wait for sqlite3-wasm to be ready (part of polyfill) injectLogger(sqlite); - const csn = state.cds.compile({ 'model.cds': modelSource }); + // unify single-file (named models) and multi-file map (bookshop) + const csn = state.cds.compile(typeof model === 'string' ? { 'model.cds': model } : model); + if (namespace) csn.namespace = namespace; state.cds.model = csn; state.cds.db = await state.cds.connect.to('db'); - await state.cds.deploy(csn, null, csvs ?? {}).to(state.cds.db); + + if (serve) { + const express = (await import('express')).default; + const app = express(); + await state.cds.serve('all').from(csn).in(app); + } + + // `js` snippet bodies reference `cds` as a free identifier; @sap/cds installs SELECT/INSERT/… + // on the worker global itself, but `cds` is not auto-global, so expose it explicitly. + self.cds = state.cds; +} + +const AsyncFunction = async function () {}.constructor; + +// Runs a ```js live``` snippet: rewrites it to return its last expression (compile), then evaluates +// it in the worker's global scope so `cds`, `SELECT`, `INSERT`, … resolve. Async snippets are traced +// for SQL; sync ones return without a SQL tab (matching the pre-worker main-thread behavior). +async function evalJS(code, isAsync) { + const cds = state.cds; + const source = compile(code); + + function resultTabs(result, kind) { + if (kind === 'json') { + let yaml + try { yaml = cds.compile.to.yaml(result) } catch {/* ignore */} + if (yaml) return [ + { value: yaml, kind: 'yaml', name: 'Result (as yaml)' }, + { value: JSON.stringify(result, null, 2), kind: 'json', name: 'Result (raw)' }, + ] + } + return [{ value: result ? typeof result !== 'string' ? JSON.stringify(result, null, 2) : result : "success", kind, name: 'Result' }] + } + + if (isAsync) { + let fn; + try { fn = new AsyncFunction(source) } + catch { fn = new AsyncFunction(code) } // rewrite had a syntax error -> run the code unmodified + state.sqlLog.length = 0; + const result = await fn(); + const formatted = state.sqlLog.map(simpleSqlFormat).join('\n\n-------\n'); + const kind = result ? 'json' : 'plaintext' + return [ + ...resultTabs(result, kind), + { value: formatted, kind: 'sql', name: 'SQL' } + ]; + } + + let fn; + try { fn = new Function(source) } + catch { fn = new Function(code) } // rewrite had a syntax error -> run the code unmodified + const result = fn(); + const kind = result ? 'json' : 'plaintext' + return resultTabs(result, kind); +} + +// Runs a ```cds live``` / ```cql live``` snippet: cds.ql(query) -> cds.db.run, traced for SQL. +async function runQuery(query) { + state.sqlLog.length = 0; + const cqn = state.cds.ql(query); + const result = await state.cds.db.run(cqn); + const formatted = state.sqlLog.map(simpleSqlFormat).join('\n\n-------\n'); + return [ + { value: result, kind: 'json', name: 'Result' }, + { value: formatted, kind: 'sql', name: 'SQL' }, + { value: cqn, kind: 'json', name: 'CQN' }, + ]; } self.onmessage = async ({ data: { type, id, payload } }) => { try { if (type === 'init') { - state.initPromise = init(payload.modelSource, payload.csvs); + state.initPromise = init(payload); await state.initPromise; self.postMessage({ type: 'ready' }); - } else if (type === 'query') { + } else if (type === 'run') { if (!state.initPromise) throw new Error('Worker not initialized'); await state.initPromise; - state.sqlLog.length = 0; - const cqn = state.cds.ql(payload.query); - const result = await state.cds.db.run(cqn); - const formatted = state.sqlLog.map(simpleSqlFormat).join('\n\n-------\n'); - self.postMessage({ type: 'result', id, result: [ - { value: result, kind: 'json', name: 'Result' }, - { value: formatted, kind: 'sql', name: 'SQL' }, - { value: cqn, kind: 'json', name: 'CQN' }, - ]}); + const result = payload.language === 'js' + ? await evalJS(payload.query, payload.isAsync) + : await runQuery(payload.query); + self.postMessage({ type: 'result', id, result }); } } catch (err) { - self.postMessage({ type: 'error', id, error: err.message ?? String(err) }); + self.postMessage({ type: 'error', id, error: err.message ?? String(err), stack: err.stack }); } }; + +// ---- ```js live``` source rewriting helpers (moved here from runners.js) ---- + +function compile(code) { + const stmts = splitTopLevelStatements(code) + if (!stmts.length) return code + const last = stmts[stmts.length - 1] + + // last statement already returns, or is a control-flow/declaration keyword -> leave the code as is + if (/^(return|throw|if|for|while|function|class|import|export)\b/.test(last.text)) return code + + // anchored right after the keyword so we don't match "=" occurring inside the initializer, e.g. in a template literal + const declRe = /^(?:let|const|var)\s+([A-Za-z_$][\w$]*)\s*=/ + if (declRe.test(last.text)) { + // last statement declares a variable, e.g. "let result = 1+1" -> collect all top-level declarations in the + // snippet so earlier ones aren't silently dropped, e.g. comparing "let q = ...; let p = ..." side by side + const names = stmts.map(s => s.text.match(declRe)?.[1]).filter(Boolean) + return names.length > 1 + ? `${code}\nreturn { ${names.join(', ')} };` + : `${code}\nreturn ${names[0]};` + } + + // last statement isn't a declaration -> treat it (possibly spanning multiple lines) as the expression to return + return `${code.slice(0, last.start)}\nreturn (\n${last.text.replace(/;\s*$/, '')}\n);` +} + + +// splits code into its top-level statements (ignoring newlines/semicolons nested inside brackets, strings, +// template literals or comments), so multi-line statements like object literals are kept intact as one unit +function splitTopLevelStatements(code) { + const scrubbed = blankComments(code) // same length as code, but with comments replaced by spaces + const stmts = [] + let start = 0, depth = 0, i = 0 + while (i < scrubbed.length) { + const c = scrubbed[i] + if (c === '"' || c === "'") { i = skipString(scrubbed, i, c); continue } + if (c === '`') { i = skipTemplate(scrubbed, i); continue } + if (c === '(' || c === '{' || c === '[') { depth++; i++; continue } + if (c === ')' || c === '}' || c === ']') { depth--; i++; continue } + if (depth <= 0 && (c === ';' || c === '\n')) { + const text = scrubbed.slice(start, i).trim() + if (text) stmts.push({ text, start }) + i++; start = i; continue + } + i++ + } + const text = scrubbed.slice(start).trim() + if (text) stmts.push({ text, start }) + return stmts +} + +// replaces line and block comments with spaces of the same length, so a trailing comment (e.g. after the last +// statement, or commented-out code on its own line) is never mistaken for code, while offsets stay unchanged +function blankComments(code) { + let out = '' + let i = 0 + while (i < code.length) { + const c = code[i] + if (c === '/' && code[i + 1] === '/') { while (i < code.length && code[i] !== '\n') { out += ' '; i++ }; continue } + if (c === '/' && code[i + 1] === '*') { + while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) { out += code[i] === '\n' ? '\n' : ' '; i++ } + out += ' '; i += 2; continue + } + if (c === '"' || c === "'") { const j = skipString(code, i, c); out += code.slice(i, j); i = j; continue } + if (c === '`') { const j = skipTemplate(code, i); out += code.slice(i, j); i = j; continue } + out += c; i++ + } + return out +} + +// skips a single- or double-quoted string starting at code[i], returning the index right after the closing quote +function skipString(code, i, quote) { + i++ + while (i < code.length && code[i] !== quote) { if (code[i] === '\\') i++; i++ } + return i + 1 +} + +// skips a template literal starting at code[i] (the opening backtick), diving into ${...} interpolations +function skipTemplate(code, i) { + i++ + while (i < code.length) { + if (code[i] === '\\') { i += 2; continue } + if (code[i] === '`') return i + 1 + if (code[i] === '$' && code[i + 1] === '{') { i = skipBraces(code, i + 2); continue } + i++ + } + return i +} + +// skips forward to the '}' balancing the '${' whose contents start at code[i] +function skipBraces(code, i) { + let depth = 1 + while (i < code.length && depth > 0) { + const c = code[i] + if (c === '"' || c === "'") { i = skipString(code, i, c); continue } + else if (c === '`') { i = skipTemplate(code, i); continue } + else if (c === '{') depth++ + else if (c === '}') depth-- + i++ + } + return i +} diff --git a/.vitepress/theme/components/cds-playground/runners.js b/.vitepress/theme/components/cds-playground/runners.js index 93d2413684..caa401b789 100644 --- a/.vitepress/theme/components/cds-playground/runners.js +++ b/.vitepress/theme/components/cds-playground/runners.js @@ -1,127 +1,10 @@ -function simpleSqlFormat(sql) { - return sql - .replace(/\b(select|from|where|group by|order by|having|limit|offset|join|left join|right join|inner join|outer join)\b/gi, "\n$1") - .replace(/\b(and|or)\b/gi, "\n $1") - .replace(/,\s*/g, ",\n ") - .replace(/\n{2,}/g, "\n") - .trim(); -} - -let sql; -function injectLogger(sqlite) { - const sqlLog = []; - const { prototype } = sqlite().constructor; - const { prepare : original } = prototype; - prototype.prepare = function prepare(sql) { - sqlLog.push(sql); - return original.call(this, sql); - } - - sqlLog.trace = async function trace(cb) { - sqlLog.length = 0; - const result = await cb(); - return {result, trace: [...sqlLog], formatted: sqlLog.map(simpleSqlFormat).join('\n\n-------\n')}; - } - - sql = sqlLog; - return sqlLog; -} - -/** @returns {Promise} */ -async function initialize() { - const cds = (await import('@sap/cds')).default; - const express = (await import('express')).default; - const templates = (await import('virtual:templates')).default; - const sqlite = (await import('better-sqlite3')).default; - - const { bookshop } = templates - const model = Object.fromEntries(bookshop - ?.filter(file => file.path.endsWith('.cds') || file.path.startsWith('@sap/cds')) - ?.map(file => [file.path, file.content]) ?? []) - - const csvs = Object.fromEntries(bookshop - ?.filter(f => f.path.endsWith('.csv')) - ?.map(f => [f.path, f.content]) ?? []) - - window.cds = cds - //======= compile a csn model ======= - const csn = cds.compile(model); - csn.namespace = 'sap.capire.bookshop'; - - //======= start a cds server ======= - await sqlite.initialized // wait for sqlite3-wasm to be ready (part of polyfill) - injectLogger(sqlite); - - cds.db = await cds.connect.to('db'); - await cds.deploy(csn, null, csvs).to(cds.db); - - const app = express(); - await cds.serve('all').from(csn).in(app); - - return cds; -} - -/** @type {ReturnType} */ -let initialized; -if (!import.meta.env.SSR) { - // runs only in the browser - initialized = initialize(); -} - -const AsyncFunction = async function () {}.constructor; -async function evalJS(code, isAsync) { - const cds = await initialized; - const source = compile(code); - - function resultTabs(result, kind) { - if (kind === 'json') { - let yaml - try { yaml = cds.compile.to.yaml(result) } catch {/* ignore */} - if (yaml) return [ - { value: yaml, kind: 'yaml', name: 'Result (as yaml)' }, - { value: JSON.stringify(result, null, 2), kind: 'json', name: 'Result (raw)' }, - ] - } - return [{ value: result ? typeof result !== 'string' ? JSON.stringify(result, null, 2) : result : "success", kind, name: 'Result' }] - } - - if (isAsync) { - let fn; - try { fn = new AsyncFunction(source) } - catch { fn = new AsyncFunction(code) } // rewrite had a syntax error -> run the code unmodified - const { result, formatted } = await sql.trace(fn); - const kind = result? 'json' : 'plaintext' - return [ - ...resultTabs(result, kind), - { value: formatted, kind: 'sql', name: 'SQL'} - ]; - } - - let fn; - try { fn = new Function(source) } - catch { fn = new Function(code) } // rewrite had a syntax error -> run the code unmodified - const result = fn(); - const kind = result? 'json' : 'plaintext' - return resultTabs(result, kind); -} +import templates from 'virtual:templates' -async function cdsQL(query) { - const cds = await initialized; - const cqn = cds.ql(query); - - const { result, formatted } = await sql.trace(() => cds.db.run(cqn)); - return [ - { value: result, kind: 'json', name: 'Result' }, - { value: formatted, kind: 'sql', name: 'SQL'}, - { value: cqn, kind: 'json', name: 'CQN' }, - ]; -} - -// Worker pool: one worker per model source string, shared across all LiveCode instances +// Worker pool: one worker per model (the shared bookshop model, plus one per named model source), +// created lazily on first evaluation and reused across all LiveCode instances on the page. const workerPool = new Map(); -function getOrCreateWorker(modelSource, csvs) { - const key = csvs ? `${modelSource}\0${JSON.stringify(csvs)}` : modelSource; +function getOrCreateWorker(key, initPayload) { if (workerPool.has(key)) return workerPool.get(key); const worker = new Worker(new URL('./cds-worker.js', import.meta.url), { type: 'module' }); const initPromise = new Promise((resolve, reject) => { @@ -131,136 +14,62 @@ function getOrCreateWorker(modelSource, csvs) { e.data.type === 'ready' ? resolve() : reject(new Error(e.data.error)); }); worker.addEventListener('error', (e) => reject(e.error ?? new Error(e.message)), { once: true }); - worker.postMessage({ type: 'init', payload: { modelSource, csvs } }); + worker.postMessage({ type: 'init', payload: initPayload }); }); const entry = { worker, initPromise }; workerPool.set(key, entry); return entry; } -async function runWithModel(query, modelSource, csvs) { - const { worker, initPromise } = getOrCreateWorker(modelSource, csvs); - await initPromise; - return new Promise((resolve, reject) => { +// Lazily initialize (on first call) the worker for `key`, then run one snippet against it. Every call +// gets a unique id so concurrent evaluations on the same page don't cross-talk. +function runOnWorker(key, initPayload, query, language, isAsync) { + const { worker, initPromise } = getOrCreateWorker(key, initPayload); + return initPromise.then(() => new Promise((resolve, reject) => { const id = crypto.randomUUID(); function handler(e) { if (e.data.id !== id) return; worker.removeEventListener('message', handler); - e.data.type === 'error' ? reject(new Error(e.data.error)) : resolve(e.data.result); + e.data.type === 'error' + ? reject(Object.assign(new Error(e.data.error), { stack: e.data.stack })) + : resolve(e.data.result); } worker.addEventListener('message', handler); - worker.postMessage({ type: 'query', id, payload: { query } }); - }); -} - -export { - evalJS, - cdsQL, - runWithModel, -} - -export const runners = { - js: evalJS, - cql: cdsQL, - cds: cdsQL, + worker.postMessage({ type: 'run', id, payload: { query, language, isAsync } }); + })); } -function compile(code) { - const stmts = splitTopLevelStatements(code) - if (!stmts.length) return code - const last = stmts[stmts.length - 1] - - // last statement already returns, or is a control-flow/declaration keyword -> leave the code as is - if (/^(return|throw|if|for|while|function|class|import|export)\b/.test(last.text)) return code - - // anchored right after the keyword so we don't match "=" occurring inside the initializer, e.g. in a template literal - const declRe = /^(?:let|const|var)\s+([A-Za-z_$][\w$]*)\s*=/ - if (declRe.test(last.text)) { - // last statement declares a variable, e.g. "let result = 1+1" -> collect all top-level declarations in the - // snippet so earlier ones aren't silently dropped, e.g. comparing "let q = ...; let p = ..." side by side - const names = stmts.map(s => s.text.match(declRe)?.[1]).filter(Boolean) - return names.length > 1 - ? `${code}\nreturn { ${names.join(', ')} };` - : `${code}\nreturn ${names[0]};` - } - - // last statement isn't a declaration -> treat it (possibly spanning multiple lines) as the expression to return - return `${code.slice(0, last.start)}\nreturn (\n${last.text.replace(/;\s*$/, '')}\n);` -} - - -// splits code into its top-level statements (ignoring newlines/semicolons nested inside brackets, strings, -// template literals or comments), so multi-line statements like object literals are kept intact as one unit -function splitTopLevelStatements(code) { - const scrubbed = blankComments(code) // same length as code, but with comments replaced by spaces - const stmts = [] - let start = 0, depth = 0, i = 0 - while (i < scrubbed.length) { - const c = scrubbed[i] - if (c === '"' || c === "'") { i = skipString(scrubbed, i, c); continue } - if (c === '`') { i = skipTemplate(scrubbed, i); continue } - if (c === '(' || c === '{' || c === '[') { depth++; i++; continue } - if (c === ')' || c === '}' || c === ']') { depth--; i++; continue } - if (depth <= 0 && (c === ';' || c === '\n')) { - const text = scrubbed.slice(start, i).trim() - if (text) stmts.push({ text, start }) - i++; start = i; continue - } - i++ - } - const text = scrubbed.slice(start).trim() - if (text) stmts.push({ text, start }) - return stmts +// Runs a snippet against a named model (```cds live model=FooBar```), each isolated in its own worker. +function runWithModel(query, modelSource, csvs, language = 'cds', isAsync = false) { + const key = csvs ? `${modelSource}\0${JSON.stringify(csvs)}` : modelSource; + return runOnWorker(key, { model: modelSource, csvs }, query, language, isAsync); } -// replaces line and block comments with spaces of the same length, so a trailing comment (e.g. after the last -// statement, or commented-out code on its own line) is never mistaken for code, while offsets stay unchanged -function blankComments(code) { - let out = '' - let i = 0 - while (i < code.length) { - const c = code[i] - if (c === '/' && code[i + 1] === '/') { while (i < code.length && code[i] !== '\n') { out += ' '; i++ }; continue } - if (c === '/' && code[i + 1] === '*') { - while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) { out += code[i] === '\n' ? '\n' : ' '; i++ } - out += ' '; i += 2; continue - } - if (c === '"' || c === "'") { const j = skipString(code, i, c); out += code.slice(i, j); i = j; continue } - if (c === '`') { const j = skipTemplate(code, i); out += code.slice(i, j); i = j; continue } - out += c; i++ - } - return out +// The default bookshop model, built once from the bundled templates and shared by all default snippets. +let bookshopInitPayload; +function bookshopPayload() { + if (bookshopInitPayload) return bookshopInitPayload; + const { bookshop } = templates + const model = Object.fromEntries((bookshop ?? []) + .filter(file => file.path.endsWith('.cds') || file.path.startsWith('@sap/cds')) + .map(file => [file.path, file.content])) + const csvs = Object.fromEntries((bookshop ?? []) + .filter(f => f.path.endsWith('.csv')) + .map(f => [f.path, f.content])) + return (bookshopInitPayload = { model, csvs, namespace: 'sap.capire.bookshop', serve: true }) } -// skips a single- or double-quoted string starting at code[i], returning the index right after the closing quote -function skipString(code, i, quote) { - i++ - while (i < code.length && code[i] !== quote) { if (code[i] === '\\') i++; i++ } - return i + 1 +function runBookshop(query, language, isAsync) { + return runOnWorker('bookshop', bookshopPayload(), query, language, isAsync); } -// skips a template literal starting at code[i] (the opening backtick), diving into ${...} interpolations -function skipTemplate(code, i) { - i++ - while (i < code.length) { - if (code[i] === '\\') { i += 2; continue } - if (code[i] === '`') return i + 1 - if (code[i] === '$' && code[i + 1] === '{') { i = skipBraces(code, i + 2); continue } - i++ - } - return i +export { + runBookshop, + runWithModel, } -// skips forward to the '}' balancing the '${' whose contents start at code[i] -function skipBraces(code, i) { - let depth = 1 - while (i < code.length && depth > 0) { - const c = code[i] - if (c === '"' || c === "'") { i = skipString(code, i, c); continue } - else if (c === '`') { i = skipTemplate(code, i); continue } - else if (c === '{') depth++ - else if (c === '}') depth-- - i++ - } - return i +export const runners = { + js: (code, isAsync) => runBookshop(code, 'js', isAsync), + cql: (query) => runBookshop(query, 'cql'), + cds: (query) => runBookshop(query, 'cds'), } diff --git a/cds/cxl.md b/cds/cxl.md index 487abe50e5..be0fa5fcd5 100644 --- a/cds/cxl.md +++ b/cds/cxl.md @@ -41,8 +41,8 @@ You can also edit the query, making this your personal playground. :::info Application Context The cds model initialized on this page is a slightly modified version of the [capire/bookshop](https://github.com/capire/bookshop). -All samples run on a single browser-local `cds` instance, you can access it via the dev tools -or run statements in the following code block: +All samples run on a shared, browser-local `cds` instance. Edit and run statements in the +following code block to try it out: ```js live async await INSERT.into('Books').entries( From 14bd58840e331a2c316cccd6dc06ac04bcfe4f29 Mon Sep 17 00:00:00 2001 From: Johannes Vogt Date: Wed, 19 Aug 2026 18:05:37 +0200 Subject: [PATCH 17/22] always async + linked csn --- .vitepress/lib/cds-playground/md-live-code.ts | 4 +-- .../components/cds-playground/cds-worker.js | 32 +++++++------------ cds/cxl.md | 2 +- node.js/cds-ql.md | 14 ++++---- 4 files changed, 21 insertions(+), 31 deletions(-) diff --git a/.vitepress/lib/cds-playground/md-live-code.ts b/.vitepress/lib/cds-playground/md-live-code.ts index 49f4bbcdad..97689ef564 100644 --- a/.vitepress/lib/cds-playground/md-live-code.ts +++ b/.vitepress/lib/cds-playground/md-live-code.ts @@ -27,8 +27,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) * example: ```cds live as=cql * - readonly: make the code block readonly * example: ```cds live readonly - * - async: run the query asynchronously - * example: ```js live async * * Named model definitions (static, non-live): * - ```cds model=FooBar — defines a named model; rendered as a plain code block @@ -140,7 +138,7 @@ export function install(md: MarkdownRenderer) { if (highlightSpec) props.highlightLines = highlightSpec if (kv.result) props.resultKind = kv.result - const liveFlags = ['readonly', 'async'].filter(k => flags.has(k)) + const liveFlags = ['readonly'].filter(k => flags.has(k)) const content = tokens[idx].content.trim() return ` `${k}="${v}"`).join(' ')} ${liveFlags.join(' ')}>` diff --git a/.vitepress/theme/components/cds-playground/cds-worker.js b/.vitepress/theme/components/cds-playground/cds-worker.js index dcc683e037..d09d51da5f 100644 --- a/.vitepress/theme/components/cds-playground/cds-worker.js +++ b/.vitepress/theme/components/cds-playground/cds-worker.js @@ -50,7 +50,7 @@ async function init({ model, csvs, namespace, serve }) { // unify single-file (named models) and multi-file map (bookshop) const csn = state.cds.compile(typeof model === 'string' ? { 'model.cds': model } : model); if (namespace) csn.namespace = namespace; - state.cds.model = csn; + state.cds.model = cds.compile.for.nodejs(csn); state.cds.db = await state.cds.connect.to('db'); await state.cds.deploy(csn, null, csvs ?? {}).to(state.cds.db); @@ -87,26 +87,18 @@ async function evalJS(code, isAsync) { return [{ value: result ? typeof result !== 'string' ? JSON.stringify(result, null, 2) : result : "success", kind, name: 'Result' }] } - if (isAsync) { - let fn; - try { fn = new AsyncFunction(source) } - catch { fn = new AsyncFunction(code) } // rewrite had a syntax error -> run the code unmodified - state.sqlLog.length = 0; - const result = await fn(); - const formatted = state.sqlLog.map(simpleSqlFormat).join('\n\n-------\n'); - const kind = result ? 'json' : 'plaintext' - return [ - ...resultTabs(result, kind), - { value: formatted, kind: 'sql', name: 'SQL' } - ]; - } - let fn; - try { fn = new Function(source) } - catch { fn = new Function(code) } // rewrite had a syntax error -> run the code unmodified - const result = fn(); + try { fn = new AsyncFunction(source) } + catch { fn = new AsyncFunction(code) } // rewrite had a syntax error -> run the code unmodified + state.sqlLog.length = 0; + let result = await fn(); + if (result?.__return) result = result.__return + const formatted = state.sqlLog.map(simpleSqlFormat).join('\n\n-------\n'); const kind = result ? 'json' : 'plaintext' - return resultTabs(result, kind); + return [ + ...resultTabs(result, kind), + { value: formatted, kind: 'sql', name: 'SQL' } + ]; } // Runs a ```cds live``` / ```cql live``` snippet: cds.ql(query) -> cds.db.run, traced for SQL. @@ -163,7 +155,7 @@ function compile(code) { } // last statement isn't a declaration -> treat it (possibly spanning multiple lines) as the expression to return - return `${code.slice(0, last.start)}\nreturn (\n${last.text.replace(/;\s*$/, '')}\n);` + return `${code.slice(0, last.start)}\nreturn { __return: (\n${last.text.replace(/;\s*$/, '')}\n) };` } diff --git a/cds/cxl.md b/cds/cxl.md index be0fa5fcd5..1098a661ef 100644 --- a/cds/cxl.md +++ b/cds/cxl.md @@ -44,7 +44,7 @@ The cds model initialized on this page is a slightly modified version of the [ca All samples run on a shared, browser-local `cds` instance. Edit and run statements in the following code block to try it out: -```js live async +```js live await INSERT.into('Books').entries( { ID: 2, author_ID: 150, title: 'Eldorado' } ) diff --git a/node.js/cds-ql.md b/node.js/cds-ql.md index 7c9f7c8d04..7af70a344a 100644 --- a/node.js/cds-ql.md +++ b/node.js/cds-ql.md @@ -97,19 +97,19 @@ While both [CQL](../cds/cql) / [CQN](../cds/cqn) as well as the fluent API of `c Queries are executed by passing them to a service's [`srv.run()`](core-services#srv-run-query) method, for example, to the primary database: -```js live async +```js live let query = SELECT `ID,title` .from `Books` let books = await cds.db.run (query) ``` Alternatively, you can just `await` a constructed query, which by default passes the query to `cds.db.run()`. So, the following is equivalent to the above: -```js live async +```js live let books = await SELECT `ID,title` .from `Books` ``` Instead of a database service, you can also send queries to other services, local or remote ones. For example: -```js live async +```js live const cats = await cds.connect.to ('CatalogService') let query = SELECT `ID,title` .from `Books` let books = await cats.run (query) @@ -120,7 +120,7 @@ return {query, books} The APIs are also available through [`cds.Service`'s CRUD-style Convenience API](core-services#crud-style-api), for example: -```js live async +```js live const db = cds.db await db.read`Books`.where`ID=${201}`.orderBy`title` ``` @@ -131,7 +131,7 @@ await db.read`Books`.where`ID=${201}`.orderBy`title` Constructing queries doesn't execute them immediately, but just captures the given query information. Very much like functions in JavaScript, queries are first-class objects, which can be assigned to variables, modified, passed as arguments, or returned from functions. Let's investigate this somewhat more, given this example: -```js live async +```js live cats = await cds.connect.to('CatalogService')//> connected via OData PoesBooks = SELECT.from ('Books') .where `author like '%Poe%'` books = await cats.get (PoesBooks) @@ -153,7 +153,7 @@ This is what happens behind the scenes: You can also combine queries much like sub selects in SQL to form more complex queries as shown in this example: -```js live async +```js live let input = '%Brontë%' let Authors = SELECT `ID` .from `Authors` .where `name like ${ input }` let Books = SELECT.from `Books` .where `author_ID in ${ Authors }` @@ -164,7 +164,7 @@ await cds.run (Books) //> late/no materialization of Authors With that we leverage late materialization, offered by SQL databases. Compare that to inferior imperative programming: -```js live async +```js live let input = '%Brontë%' let Authors = await SELECT `ID` .from `Authors` .where `name like ${ input }` // looping over eagerly materialized Auxthors From 17ca4638a8c2f7e6098af3cfcef6ae386e61df6e Mon Sep 17 00:00:00 2001 From: Johannes Vogt Date: Wed, 19 Aug 2026 18:11:46 +0200 Subject: [PATCH 18/22] lint --- .../theme/components/cds-playground/cds-worker.js | 6 +++--- .../theme/components/cds-playground/runners.js | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.vitepress/theme/components/cds-playground/cds-worker.js b/.vitepress/theme/components/cds-playground/cds-worker.js index d09d51da5f..6b92492104 100644 --- a/.vitepress/theme/components/cds-playground/cds-worker.js +++ b/.vitepress/theme/components/cds-playground/cds-worker.js @@ -50,7 +50,7 @@ async function init({ model, csvs, namespace, serve }) { // unify single-file (named models) and multi-file map (bookshop) const csn = state.cds.compile(typeof model === 'string' ? { 'model.cds': model } : model); if (namespace) csn.namespace = namespace; - state.cds.model = cds.compile.for.nodejs(csn); + state.cds.model = state.cds.compile.for.nodejs(csn); state.cds.db = await state.cds.connect.to('db'); await state.cds.deploy(csn, null, csvs ?? {}).to(state.cds.db); @@ -71,7 +71,7 @@ const AsyncFunction = async function () {}.constructor; // Runs a ```js live``` snippet: rewrites it to return its last expression (compile), then evaluates // it in the worker's global scope so `cds`, `SELECT`, `INSERT`, … resolve. Async snippets are traced // for SQL; sync ones return without a SQL tab (matching the pre-worker main-thread behavior). -async function evalJS(code, isAsync) { +async function evalJS(code) { const cds = state.cds; const source = compile(code); @@ -124,7 +124,7 @@ self.onmessage = async ({ data: { type, id, payload } }) => { if (!state.initPromise) throw new Error('Worker not initialized'); await state.initPromise; const result = payload.language === 'js' - ? await evalJS(payload.query, payload.isAsync) + ? await evalJS(payload.query) : await runQuery(payload.query); self.postMessage({ type: 'result', id, result }); } diff --git a/.vitepress/theme/components/cds-playground/runners.js b/.vitepress/theme/components/cds-playground/runners.js index caa401b789..ab89ff51a6 100644 --- a/.vitepress/theme/components/cds-playground/runners.js +++ b/.vitepress/theme/components/cds-playground/runners.js @@ -23,7 +23,7 @@ function getOrCreateWorker(key, initPayload) { // Lazily initialize (on first call) the worker for `key`, then run one snippet against it. Every call // gets a unique id so concurrent evaluations on the same page don't cross-talk. -function runOnWorker(key, initPayload, query, language, isAsync) { +function runOnWorker(key, initPayload, query, language) { const { worker, initPromise } = getOrCreateWorker(key, initPayload); return initPromise.then(() => new Promise((resolve, reject) => { const id = crypto.randomUUID(); @@ -35,14 +35,14 @@ function runOnWorker(key, initPayload, query, language, isAsync) { : resolve(e.data.result); } worker.addEventListener('message', handler); - worker.postMessage({ type: 'run', id, payload: { query, language, isAsync } }); + worker.postMessage({ type: 'run', id, payload: { query, language } }); })); } // Runs a snippet against a named model (```cds live model=FooBar```), each isolated in its own worker. -function runWithModel(query, modelSource, csvs, language = 'cds', isAsync = false) { +function runWithModel(query, modelSource, csvs, language = 'cds') { const key = csvs ? `${modelSource}\0${JSON.stringify(csvs)}` : modelSource; - return runOnWorker(key, { model: modelSource, csvs }, query, language, isAsync); + return runOnWorker(key, { model: modelSource, csvs }, query, language); } // The default bookshop model, built once from the bundled templates and shared by all default snippets. @@ -59,8 +59,8 @@ function bookshopPayload() { return (bookshopInitPayload = { model, csvs, namespace: 'sap.capire.bookshop', serve: true }) } -function runBookshop(query, language, isAsync) { - return runOnWorker('bookshop', bookshopPayload(), query, language, isAsync); +function runBookshop(query, language) { + return runOnWorker('bookshop', bookshopPayload(), query, language); } export { @@ -69,7 +69,7 @@ export { } export const runners = { - js: (code, isAsync) => runBookshop(code, 'js', isAsync), + js: (code) => runBookshop(code, 'js'), cql: (query) => runBookshop(query, 'cql'), cds: (query) => runBookshop(query, 'cds'), } From 60c0dc1b315748c8aa7546b7b9cef76fedcc0791 Mon Sep 17 00:00:00 2001 From: Johannes Vogt Date: Wed, 19 Aug 2026 18:15:34 +0200 Subject: [PATCH 19/22] fix async for single declared variables --- .vitepress/theme/components/cds-playground/cds-worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vitepress/theme/components/cds-playground/cds-worker.js b/.vitepress/theme/components/cds-playground/cds-worker.js index 6b92492104..bdb7b7336d 100644 --- a/.vitepress/theme/components/cds-playground/cds-worker.js +++ b/.vitepress/theme/components/cds-playground/cds-worker.js @@ -151,7 +151,7 @@ function compile(code) { const names = stmts.map(s => s.text.match(declRe)?.[1]).filter(Boolean) return names.length > 1 ? `${code}\nreturn { ${names.join(', ')} };` - : `${code}\nreturn ${names[0]};` + : `${code}\nreturn { __return: (${names[0]}) };` } // last statement isn't a declaration -> treat it (possibly spanning multiple lines) as the expression to return From b3aa55a6dd859e84523bdc31451de845e3d0593b Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Thu, 20 Aug 2026 09:24:49 +0200 Subject: [PATCH 20/22] Allow tabbing outside the editor --- .vitepress/theme/components/cds-playground/MonacoEditor.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/.vitepress/theme/components/cds-playground/MonacoEditor.vue b/.vitepress/theme/components/cds-playground/MonacoEditor.vue index 6d16244bd4..9bae1d12e8 100644 --- a/.vitepress/theme/components/cds-playground/MonacoEditor.vue +++ b/.vitepress/theme/components/cds-playground/MonacoEditor.vue @@ -65,6 +65,7 @@ async function createEditor() { try { overviewRulerLanes: 0, overviewRulerBorder: false, scrollBeyondLastLine: false, + tabFocusMode: true, scrollbar: { vertical: 'hidden', horizontal: 'hidden', From f5ca6739b6d2122f8cec61e7c9b0d620ac226582 Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Thu, 20 Aug 2026 10:52:05 +0200 Subject: [PATCH 21/22] Enable snippets for `SQL Injection` section --- node.js/cds-ql.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/node.js/cds-ql.md b/node.js/cds-ql.md index 7af70a344a..3e8f50686b 100644 --- a/node.js/cds-ql.md +++ b/node.js/cds-ql.md @@ -182,7 +182,7 @@ return books ## Avoiding SQL Injection All the APIs are designed to avoid [SQL Injection](https://wikipedia.org/wiki/SQL_injection) by default. For example, let's see how the following code would be executed: -```js +```js live let input = 201 //> might be entered by end users let books = await SELECT.from `Books` .where `ID=${input}` ``` @@ -206,10 +206,10 @@ dbc.run (sql, [201]) The only mistake you could make is to imperatively concatenate user input with CQL or SQL fragments, instead of using the tagged strings or other options promoted by `cds.ql`. For example, assumed you had written the above code sample like that: -```js +```js live let input = 201 //> might be entered by end users -let books = await SELECT.from `Books` .where ('ID='+input) -let bookz = await SELECT.from `Books` .where (`ID=${input}`) +let books = await SELECT.from `Books` .where ('ID='+input) // BAD +let bookz = await SELECT.from `Books` .where (`ID=${input}`) // BAD ``` > **Note** also that tagged template strings never have surrounding parentheses! That means, the third line above does the very same string concatenation as the second line. @@ -218,7 +218,6 @@ A malicious user might enter some SQL code fragment like that: ```sql 0; DELETE from Books; -- gotcha! ``` -{style="margin: 10px 40px"} In effect, your generated SQL statements would effectively look like that: From 508b64c066bef34b58b62f8c334b3b06dee32c43 Mon Sep 17 00:00:00 2001 From: Johannes Vogt Date: Thu, 20 Aug 2026 11:46:22 +0200 Subject: [PATCH 22/22] replace patch with rolldown option: keepNames --- .vitepress/config.js | 1 + patches/vite-plugin-cds+0.3.5.patch | 48 ----------------------------- 2 files changed, 1 insertion(+), 48 deletions(-) delete mode 100644 patches/vite-plugin-cds+0.3.5.patch diff --git a/.vitepress/config.js b/.vitepress/config.js index 34a4f6c702..0267cb554e 100644 --- a/.vitepress/config.js +++ b/.vitepress/config.js @@ -102,6 +102,7 @@ const config = defineConfig({ // dynamic import('@sap/cds') is emitted as native ESM instead of an iife require() shim worker: { format: 'es', + rolldownOptions: { output: { keepNames: true, } }, // Vite doesn't reuse the main `plugins` array for worker bundles; without vite-plugin-cds's // node()/cap() here, the worker build misses their Node built-in shims (e.g. lazify's module.require) plugins: () => [...playground.plugins()], diff --git a/patches/vite-plugin-cds+0.3.5.patch b/patches/vite-plugin-cds+0.3.5.patch deleted file mode 100644 index 8dcc2e4186..0000000000 --- a/patches/vite-plugin-cds+0.3.5.patch +++ /dev/null @@ -1,48 +0,0 @@ -diff --git a/node_modules/vite-plugin-cds/node/vite.js b/node_modules/vite-plugin-cds/node/vite.js -index 1245549..a28263d 100644 ---- a/node_modules/vite-plugin-cds/node/vite.js -+++ b/node_modules/vite-plugin-cds/node/vite.js -@@ -113,7 +113,43 @@ export function nodeVite() { - if (/node_modules\/vite\/dist\/client\/env.mjs$/.test(id)) { - return `${windowBootstrap}\n${code}`; - } -+ // @sap/cds's lazify() calls the real Node `module.require`, which bundler-emulated -+ // `module` objects don't implement; route it through the polyfilled global `require` instead. -+ // Matched by content only (not `id`) since the same file can be bundled under multiple -+ // resolved ids (e.g. once for the client build, once for the worker sub-build) and the -+ // literal path segment isn't reliably present in every id variant. -+ if (code.includes('module.require(id)')) { -+ return code.replaceAll('module.require(id)', 'require(id)'); -+ } -+ // @sap/cds's Query.init() derives the CQN keyword ("SELECT", "INSERT", ...) from the -+ // query-builder class's own `.name` at module-init time (`kind = self.name`), then uses -+ // `q[kind] = x` to build the CQN object. Rolldown's isolated worker sub-build can lose -+ // these classes' `.name` (resolving to `""`), corrupting every query built there (e.g. -+ // dropping the `from` clause). Make `kind` an explicit parameter instead of relying on it. -+ if (code.includes('static init() {') && code.includes('kind = self.name')) { -+ return code.replace( -+ 'static init() {\n const self = this, kind = self.name', -+ 'static init(kind = this.name) {\n const self = this' -+ ); -+ } -+ const initCall = code.match(/module\.exports = (SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|UPSERT)\.init\(\)/); -+ if (initCall) { -+ return code.replace(initCall[0], `module.exports = ${initCall[1]}.init('${initCall[1]}')`); -+ } -+ return null; -+ }, -+ renderChunk(code) { -+ // Rolldown's CJS/ESM interop can synthesize its own lazy-require wrapper (a Proxy calling -+ // `.require()`) for @sap/cds's lazify() pattern, in addition to (or instead -+ // of) the literal source text handled above. Catch it here, post-bundling, by matching the -+ // generic `.require()` member-call shape, which real Node's `module.require` -+ // is the only realistic source of in this codebase. -+ const re = /\b([a-zA-Z_$][\w$]*)\.require\(([a-zA-Z_$][\w$]*)\)/g; -+ if (re.test(code)) { -+ return { code: code.replace(re, (m, obj, arg) => `require(${arg})`), map: null }; -+ } - return null; - }, - }; - } -+