The interactive tutorials with exercises built into the documentation helped me a lot in my time:

Also included was a database "SAMPLES" (code and data) with examples for every taste. I often used it for performance testing.

Note: I give a link to version 2016.2, because since version 2017.1, for some reason, the developers have changed the display styles in the online documentation and removed some information. Local documentation does not have these disadvantages.

In addition, in the <cachesys>\dev\ folder there were numerous demos with source code for various programming languages and connection technologies (С++, C#, Java, PHP, Python, ODBC, JDBC, ADO.NET, ActiveX, XEP, etc.):

 
Incomplete content

Examining an XML Subtree

  try{

    $$$ThrowOnError(##class(%XML.XPATH.Document).CreateFromString("<a><b><c>some content</c></b></a>", .doc))
    
$$$ThrowOnError(doc.EvaluateExpression("/a""b", .field))
    
#dim obj As %XML.XPATH.DOMResult field.GetAt(1)
    
    
while obj.Read() {
      
if obj.HasValue {
        
write obj.Path,": ",obj.Value,!
      
}
    }
    
  }
catch(ex){
    
write "Error "ex.DisplayString(),!
  
}

Result:
b\c: some content

There is another way.
If you want any empty strings to always be treated as null/"" instead of "/$c(0), then there is an documented setting (within the scope of namespace), namely:

^%SYS("sql","sys","namespace",<YOUR_NAMESPACE>,"empty string")

Here is a small example:

Class dc.test Extends %Persistent
{

Property As %Integer;

Property str As %String;

/// do ##class(dc.test).Test()
ClassMethod 
Test()
{
 
do ..%KillExtent()
 
 
try{
  
  
do $system.SQL.Purge()
  
set ^%SYS("sql","sys","namespace",$namespace,"empty string")=$c(0)

  ; '' -> $c(0)
  
do ##class(%SQL.Statement).%ExecDirect(,"insert into dc.test(i,str) values(1,'')")
  
  
; null -> ""
  
do ##class(%SQL.Statement).%ExecDirect(,"insert into dc.test(i,str) values(2,null)")
  
  
do $system.SQL.Purge()
  
set ^%SYS("sql","sys","namespace",$namespace,"empty string")=""

  ; '' -> ""
  
do ##class(%SQL.Statement).%ExecDirect(,"insert into dc.test(i,str) values(11,'')")
  
  
; null -> ""
  
do ##class(%SQL.Statement).%ExecDirect(,"insert into dc.test(i,str) values(22,null)")

  zwrite ^dc.testD
  
kill ^%SYS("sql","sys","namespace",$namespace,"empty string")
 
}
 
catch(ex){
  
write ex.DisplayString(),!
 
}
}

}

USER>do ##class(dc.test).Test()
^dc.testD=4
^dc.testD(1)=$lb("",1,$c(0))
^dc.testD(2)=$lb("",2,"")
^dc.testD(3)=$lb("",11,"")
^dc.testD(4)=$lb("",22,"")

Another option just for the fun:

Include %SYS.PTools.Stats

Class dc.test Abstract ]
{

ClassMethod setValue(args...) As %Status
{
  
quit:args<2 $$$ERROR($$$DataMissing)

  $$$convertArrayToList(args,list)
  
quit:$listlength(list)'=args $$$ERROR($$$RequiredArgumentMissing)
  
  
set $list(list,*,*)="",
         
var=##class(%Utility).FormatString(list),
         
$extract(var,1,3)=$name(%sessionData), ##; or $name(%session.Data)
         
@var=args(args)
  
quit $$$OK
}

/// do ##class(dc.test).Test()
ClassMethod 
Test()
{
  
new %sessionData

  try{

    do $system.OBJ.DisplayError(..setValue("key1""val1")),
          
$system.OBJ.DisplayError(..setValue("key1""key2""key3""key4""val2")),
          
$system.OBJ.DisplayError(..setValue("key1""key2", , "key4""val3")),
          
$system.OBJ.DisplayError(..setValue())
    
    
write !
    
zwrite %sessionData
  
}
  
catch(ex){
    
write ex.DisplayString(),!
  
}
}

}

Try adding a new index and don't forget make rebuild index/tunetable/recompile class

Index idx On (prop1, prop3) [ Type = bitmap ];

Here yet need the help of @Kyle.Baxter.

PS: by the way, check

select count(*from mp.test where prop1=2

to insert the correct values in the code

%VID BETWEEN 3000000 AND 30000005

Because of this, is obtained

0 Rows(s) Affected

 

Source code

USER>##class(mp.test).Fill(5000000)

USER>##class(mp.test).Query()
count=3833346

first 5 [1:5]
ID prop3
3 3
4 3
24 3
30 3
97 3

5 Rows(s) Affected
.000328 secs

last 5 [3833342:3833346]
ID prop3
4999798 1
4999817 1
4999836 1
4999866 1
4999947 1

5 Rows(s) Affected
2.884304 secs

PS: for those who put a minus. May I ask why?

My EAV implementation is the same as your Approach 3, so it will work fine even with fully filled 4.000.000 attributes.
Since the string has a limit of 3,641,144, approaches with serial and %List are dropped.

All other things being equal, everything depends on the specific technical task: speed, support for Objects/SQL, the ability to name each attribute, the number of attributes, and so on.