Skip to content

Commit 3e2d663

Browse files
committed
Merge branch 'development' of github.com:kgrgreer/foam3 into development
2 parents eb7442e + b7cd8dc commit 3e2d663

10 files changed

Lines changed: 232 additions & 20 deletions

File tree

src/foam/core/fs/FileArrayDAODecorator.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ foam.CLASS({
5151
},
5252

5353
async function processFiles(obj) {
54+
// FileArrayDAODecorator explicitly extracts FileArray properties from 'obj' to send to a separate file storage service (fileDAO).
55+
// These transient FileArray properties on 'obj' are client-side only and generally for UI/UX purposes. There is no need to save
56+
// them to fileDAO.
5457
var props1 = obj.cls_.getAxiomsByClass(foam.core.fs.FileArray);
58+
props1 = props1.filter(p => ! ( p.transient || p.networkTransient || p.storageTransient ));
5559

5660
// clone the object if we need to update the file properties
5761
if ( props1.length > 0 ) obj = obj.clone();

src/foam/core/fs/FileDAODecorator.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@ foam.CLASS({
3131

3232
var self = this;
3333
var i = 0;
34+
35+
// FileDAODecorator explicitly extracts File properties from 'obj' to send to a separate file storage service (fileDAO).
36+
// These transient File properties on 'obj' are client-side only and generally for UI/UX purposes. There is no need to
37+
// save it to fileDAO.
3438
var props = obj.cls_.getAxiomsByClass(foam.core.fs.FileProperty);
39+
props = props.filter(p => ! ( p.transient || p.networkTransient || p.storageTransient ));
3540

3641
// clone the object if we need to update the file properties
3742
if ( props.length > 0 ) obj = obj.clone();

src/foam/dao/F3FileJournal.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ foam.CLASS({
6262
6363
getLogger().info("Replay starting");
6464
65+
// Pre-compute the parser X context once per replay. When the target
66+
// ClassInfo has no backing Java class (getObjClass() is null), thread
67+
// the ClassInfo itself through X so the parser can instantiate via
68+
// ci.newInstance() for entries that omit the class: prefix.
69+
final foam.lang.X parseX;
70+
if ( dao.getOf().getObjClass() == null ) {
71+
getLogger().warning("Class not found for of, falling back to defaultClassInfo", dao.getOf().getId());
72+
parseX = x.put("defaultClassInfo", dao.getOf());
73+
} else {
74+
parseX = x;
75+
}
76+
6577
// NOTE: explicitly calling PM constructor as create only creates
6678
// a percentage of PMs, but we want all replay statistics
6779
PM pm = new PM(dao.getOf(), "replay." + getFilename());
@@ -96,7 +108,7 @@ foam.CLASS({
96108
FObject obj;
97109
98110
public void executeJob() {
99-
obj = getParser(x).parseString(strEntry, dao.getOf().getObjClass());
111+
obj = getParser(parseX).parseString(strEntry, dao.getOf().getObjClass());
100112
}
101113
102114
public void endJob(boolean isLast) {
@@ -106,10 +118,9 @@ foam.CLASS({
106118
return;
107119
}
108120
switch ( operation ) {
109-
case OP_CREATE:
110-
dao.put(obj);
111-
break;
112-
121+
case OP_CREATE: // Workaround: treat c as p so that duplicate IDs
122+
// across journals are merged instead of silently dropped.
123+
// Real fix: make honorCreate configurable at EasyDAO level.
113124
case OP_PUT:
114125
foam.lang.FObject old = dao.find(obj.getProperty("id"));
115126
dao.put(old != null ? mergeFObject(old.fclone(), obj) : obj);

src/foam/dao/test/F3FileJournalTest.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ foam.CLASS({
204204
c = (Count) languageDAO.select(COUNT());
205205
test ( c.getValue() == 2, "Language replay count 2 "+c);
206206
207-
// Remove
207+
// Remove
208208
languageDAO.remove(language);
209209
languageDAO = new foam.dao.java.JDAO();
210210
languageDAO.setX(x);
@@ -213,6 +213,35 @@ foam.CLASS({
213213
c = (Count) languageDAO.select(COUNT());
214214
test ( c.getValue() == 1, "Language replay count 1 after remove "+c);
215215
216+
// OP_CREATE treated as OP_PUT (c-as-p)
217+
// Two c entries for the same ID: second is sparse (only firstName).
218+
// After replay, lastName from the first c entry must survive via merge.
219+
// Use FileSystemStorage context (same as JDAO does) so reader finds files on disk
220+
X fsX = testX.put(foam.core.fs.Storage.class, testX.get(foam.core.fs.FileSystemStorage.class));
221+
222+
String cAsPFile = "cAsPutJournal";
223+
foam.core.fs.Storage cAsPStorage = (foam.core.fs.Storage) fsX.get(foam.core.fs.Storage.class);
224+
try ( BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(cAsPStorage.getOutputStream(cAsPFile))) ) {
225+
bw.write("c({\\"class\\":\\"foam.core.auth.User\\",\\"id\\":999,\\"firstName\\":\\"First\\",\\"lastName\\":\\"Last\\"})");
226+
bw.newLine();
227+
bw.write("c({\\"class\\":\\"foam.core.auth.User\\",\\"id\\":999,\\"firstName\\":\\"Updated\\"})");
228+
bw.newLine();
229+
}
230+
231+
foam.dao.MDAO cAsPMdao = new foam.dao.MDAO(User.getOwnClassInfo());
232+
foam.dao.F3FileJournal cAsPJournal = new foam.dao.F3FileJournal.Builder(fsX)
233+
.setFilename(cAsPFile)
234+
.build();
235+
cAsPJournal.replay(fsX, cAsPMdao);
236+
237+
c = (Count) cAsPMdao.select(COUNT());
238+
test ( c.getValue() == 1, "c-as-p: one record after two c entries for same ID " + c);
239+
240+
User cAsPUser = (User) cAsPMdao.find(999L);
241+
test ( cAsPUser != null, "c-as-p: record found" );
242+
test ( "Updated".equals(cAsPUser.getFirstName()), "c-as-p: firstName updated by second c entry " + (cAsPUser != null ? cAsPUser.getFirstName() : "null") );
243+
test ( "Last".equals(cAsPUser.getLastName()), "c-as-p: lastName preserved from first c entry via merge " + (cAsPUser != null ? cAsPUser.getLastName() : "null") );
244+
216245
`
217246
},
218247
{

src/foam/lib/json/FObjectParser.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ public PStream parse(PStream ps, ParserContext x) {
9595
}
9696
} else {
9797
c = defaultClass;
98+
// No "class:" prefix and no Java Class supplied; fall back to
99+
// a caller-supplied default ClassInfo (threaded via X context).
100+
// Used for types whose getObjClass() returns null.
101+
if ( c == null ) {
102+
ClassInfo fallbackCi = (ClassInfo) ctx.get("defaultClassInfo");
103+
if ( fallbackCi != null ) ci = fallbackCi;
104+
}
98105
}
99106

100107
ParserContext subx = x.sub();

src/foam/parse/SimpleQueryParser.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ foam.CLASS({
291291
},
292292
{
293293
name: 'propertiesGrammar_',
294-
value: function(action, alt, nyChar, eof, join, literal, literalIC, not, notChars, optional, range,
294+
value: function(action, alt, nop, nyChar, eof, join, literal, literalIC, not, notChars, optional, range,
295295
repeat, repeat0, seq, seq1, str, sug, sym, until) {
296296

297297
let cls = this.of;
@@ -320,9 +320,23 @@ foam.CLASS({
320320
// Property or Referenced Property, the effective type of the Property
321321
let type = prop;
322322

323-
// TODO: It would be better to handle references with a custom view:
324-
// which auto-completes based on DAO searches.
325323
if ( foam.lang.Reference.isInstance(prop) ) {
324+
// Delegate to ReferenceSuggester for suggestions after = or !=
325+
propPredicates.push(seq(propertyParser, seq1(1,
326+
alt(operator('='), operator('!=')),
327+
sym('ws'),
328+
sug(nop(), {
329+
view: {
330+
class: 'foam.parse.auto.ReferenceSuggester',
331+
targetDAOKey: prop.targetDAOKey,
332+
of: prop.of
333+
},
334+
label: prop.label + ' lookup',
335+
category: 'value'
336+
})
337+
)));
338+
339+
// Resolve ID type and fall through to compareNumber/compareString.
326340
type = prop.of.ID;
327341
if ( foam.lang.IDAlias.isInstance(type) ) {
328342
type = prop.of.getAxiomByName(type.propName);
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* @license
3+
* Copyright 2026 The FOAM Authors. All Rights Reserved.
4+
* http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
foam.CLASS({
8+
package: 'foam.parse.auto',
9+
name: 'ReferenceSuggester',
10+
extends: 'foam.u2.View',
11+
12+
documentation: `
13+
A suggester view for Reference properties in the AQL search bar.
14+
Shows records from the target DAO using CitationView. SmartView passes
15+
a 'filter' string (the text typed after the operator) which is used to
16+
narrow results.
17+
For searching, prefers CONTAINS_IC on the model's searchColumns axiom
18+
(mirroring RichChoiceView); falls back to KEYWORD if none declared.
19+
Selecting a record inserts its ID.
20+
`,
21+
22+
requires: [
23+
'foam.u2.CitationView'
24+
],
25+
26+
properties: [
27+
'suggestText',
28+
{ class: 'Class', name: 'of' },
29+
{ class: 'String', name: 'targetDAOKey' },
30+
{ class: 'String', name: 'filter' },
31+
{ class: 'Int', name: 'resultLimit', value: 10 }
32+
],
33+
34+
methods: [
35+
function render() {
36+
this.addClass();
37+
var self = this;
38+
var dao = this.__subContext__[this.targetDAOKey];
39+
if ( ! dao ) return;
40+
41+
var filtered = this.filter
42+
? dao.where(this.buildFilterPredicate_(this.filter))
43+
: dao;
44+
45+
let isFirstElement = true;
46+
this
47+
.start()
48+
.select(filtered.limit(self.resultLimit), function(obj) {
49+
if ( ! isFirstElement ) {
50+
this.start().addClass('foam-parse-auto-SmartView-suggestionSeparator').end();
51+
}
52+
isFirstElement = false;
53+
this.start(self.CitationView, { data: obj })
54+
.addClass(self.myClass('row'))
55+
.on('click', function() { self.suggestText(obj.id + ' '); })
56+
.end();
57+
})
58+
.end();
59+
},
60+
61+
function buildFilterPredicate_(filter) {
62+
// Build predicates against columns in the model's searchColumns axiom:
63+
// - String columns → CONTAINS_IC(col, filter)
64+
// - Numeric columns → EQ(col, +filter) (when filter parses as number)
65+
// - Reference columns → resolved to the ID type they store, then above
66+
// - Enum/Date/Boolean/etc. are skipped (Binary.adapt would throw).
67+
// Falls back to KEYWORD when no usable columns are available.
68+
var self = this;
69+
var searchAxiom = this.of.getAxiomByName('searchColumns');
70+
var cols = ( searchAxiom && searchAxiom.columns ) || [];
71+
72+
var preds = cols
73+
.map(function(name) { return self.of.getAxiomByName(name); })
74+
.filter(function(p) { return p; })
75+
.map(function(p) { return self.columnPredicate_(p, filter); })
76+
.filter(function(pred) { return pred; });
77+
78+
if ( preds.length > 0 ) return this.OR.apply(this, preds);
79+
80+
console.warn(
81+
'[ReferenceSuggester] Falling back to KEYWORD for ' + this.of.id +
82+
' - no usable searchColumns for filter "' + filter + '". ' +
83+
'KEYWORD scans all keyword-indexed properties on the server which is ' +
84+
'less efficient. Declare `searchColumns` with String/Int properties on ' +
85+
this.of.id + ' (e.g. name, email, id) for targeted filtering.'
86+
);
87+
return this.KEYWORD(filter);
88+
},
89+
90+
function columnPredicate_(prop, filter) {
91+
// Return the best predicate for one column, or null if the column's
92+
// type can't be safely filtered by `filter`.
93+
var type = this.effectivePropertyType_(prop);
94+
95+
if ( foam.lang.String.isInstance(type) ) {
96+
return this.CONTAINS_IC(prop, filter);
97+
}
98+
if ( foam.lang.Int.isInstance(type) ) {
99+
var n = this.parseNumber_(filter);
100+
return n === null ? null : this.EQ(prop, n);
101+
}
102+
return null;
103+
},
104+
105+
function effectivePropertyType_(prop) {
106+
// A Reference doesn't store the referenced object — it stores the ID.
107+
// Unwrap Reference → target model's ID property (resolving IDAlias for
108+
// compound-ID models) so callers can check the real stored type.
109+
if ( ! foam.lang.Reference.isInstance(prop) ) return prop;
110+
var id = prop.of.ID;
111+
if ( foam.lang.IDAlias.isInstance(id) ) {
112+
id = prop.of.getAxiomByName(id.propName);
113+
}
114+
return id;
115+
},
116+
117+
function parseNumber_(str) {
118+
if ( str === '' ) return null;
119+
var n = Number(str);
120+
return isNaN(n) ? null : n;
121+
}
122+
]
123+
});

src/foam/parse/auto/SmartView.js

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ foam.CLASS({
88
package: 'foam.parse.auto',
99
name: 'DateSuggester',
1010
extends: 'foam.u2.View',
11+
12+
css:`
13+
^ {
14+
padding: 4px 0px;
15+
}
16+
`,
1117

1218
properties: [
1319
'suggestText',
@@ -20,6 +26,7 @@ foam.CLASS({
2026

2127
methods: [
2228
function render() {
29+
this.addClass();
2330
this.startContext({data: this}).add(this.DATE);
2431
this.date$.sub(() => {
2532
this.suggestText(this.date.toISOString().substring(0,10) + ' ');
@@ -120,6 +127,8 @@ foam.CLASS({
120127
css: `
121128
^ {
122129
color: $textDefault;
130+
border-radius: 4px;
131+
padding: 4px 8px;
123132
}
124133
^label {
125134
font-style: normal;
@@ -130,6 +139,10 @@ foam.CLASS({
130139
^text {
131140
color: $textSecondary;
132141
}
142+
^:hover{
143+
background-color: $backgroundBrandTertiary;
144+
cursor: pointer;
145+
}
133146
134147
^property { color: $green400; }
135148
^operator { color: $orange400; }
@@ -218,14 +231,6 @@ foam.CLASS({
218231
overflow-y: auto;
219232
z-index: 1000;
220233
}
221-
^suggestions > :not(^suggestionSeparator) {
222-
border-radius: 4px;
223-
padding: 4px 8px;
224-
}
225-
^suggestions > :not(^suggestionSeparator):hover {
226-
background-color: $backgroundBrandTertiary;
227-
cursor: pointer;
228-
}
229234
^suggestionSeparator { border-bottom: 1px solid $borderLight; }
230235
^error { border: 1px solid red !important; }
231236
`,
@@ -416,7 +421,15 @@ foam.CLASS({
416421
let keys = Object.keys(suggestions);
417422
let ss = keys.sort(compare); // Sort by section then (label or text)
418423

419-
if ( delta ) ss = ss.filter(k => suggestions[k].matches(delta));
424+
if ( delta ) ss = ss.filter(k => {
425+
let sug = suggestions[k];
426+
// Currently custom views handle their own filtering via the 'filter' property.
427+
// TODO: for Ajeet, enhancement suggestion by Sarthak:
428+
// This should probably check an interface and ignore if the view implements a searchable interface,
429+
// dont think its prudent to just assume views will always filter themselves, maybe a todo
430+
if ( sug.view ) return true;
431+
return sug.matches(delta);
432+
});
420433

421434
let parent = e.parentNode;
422435

@@ -428,6 +441,7 @@ foam.CLASS({
428441
let sug = self.suggestions[s];
429442
this.tag(sug.view || self.SuggestionView, {
430443
data: sug,
444+
filter: sug.view ? delta.trim() : '',
431445
suggestText: (text) => {
432446
self.suggestText.call(self, text, sug);
433447
}

src/foam/parse/test/SimpleQueryParserTest.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@ foam.CLASS({
211211
x.test(this.isValid(" (id=18 OR id<10) ", 'OR(EQ(foam.core.auth.User.id, 18),LT(foam.core.auth.User.id, 10))'), "Parentheses Test4: The id equal to the value or less than another value with parentheses");
212212
x.test(this.isValid(" NOT id=17 AND loginEnabled IS TRUE", "AND(NEQ(foam.core.auth.User.id, 17),EQ(foam.core.auth.User.loginEnabled, true))"), "Parentheses Test5: Negate the id equal to the value and login enabled is true with parentheses");
213213

214+
// Reference properties tests (spid resolves to String via ServiceProvider's ID type)
215+
x.test(this.isValid('spid = someProvider', 'EQ(foam.core.auth.User.spid, "someProvider")'), "Reference Test1: String-ID reference with = operator");
216+
x.test(this.isValid('spid != someProvider', 'NEQ(foam.core.auth.User.spid, "someProvider")'), "Reference Test2: String-ID reference with != operator");
217+
x.test(this.isValid('spid : provider', 'CONTAINS_IC(foam.core.auth.User.spid, "provider")'), "Reference Test3: String-ID reference with CONTAINS operator (fallthrough preserved)");
214218

215219
},
216220

src/pom.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,9 +427,10 @@ foam.POM({
427427
{ name: "foam/parse/QueryRouter", flags: "js" },
428428
{ name: "foam/parse/DateGrammar", flags: "js" },
429429
{ name: "foam/parse/DateParser", flags: "js" },
430-
{ name: "foam/parse/NumberGrammar", flags: "js" },
431-
{ name: "foam/parse/NumberParser", flags: "js" },
430+
{ name: "foam/parse/NumberGrammar", flags: "js" },
431+
{ name: "foam/parse/NumberParser", flags: "js" },
432432
{ name: "foam/parse/auto/SmartView", flags: "web" },
433+
{ name: "foam/parse/auto/ReferenceSuggester", flags: "web" },
433434
{ name: "foam/parse/FScriptParser", flags: "js" },
434435
{ name: "foam/parse/test/FScriptParserTestUser", flags: "js&test|java&test" },
435436
{ name: "foam/parse/test/FScriptParserTest", flags: "js&test|java&test" },

0 commit comments

Comments
 (0)